🥏

TypeScript 型安全かつ扱いやすいAPIリクエストクライアントを作る

2022/02/26に公開

axiosのバーションは0.19です。ちょっと古い。

axiosをラップする

いつだったか忘れたのですがどこかでaxiosのラッパーの記事を見つけてそれ真似て作ってました。今探してもでてこない。。

resourceでやっていること

  • AmplifyのAuthライブラリでのトークンチェック
  • 各HTTPメソッドのレスポンス型、ヘッダー型、リクエストbodyやparam型を付与
  • レスポンスをレスポンスクラスに入れる

responseでやってること

  • ステータスコードが200,204以外であればエラー
  • すべての返り値をResponseTypeで扱う

こんなふうに扱う

const {error, data} = await resource.get<レスポンス型, Param型>('/hoge', query)

errorにboolean値がdataにレスポンスが入ってる。errorという変数名にboolean値って普通に扱いにくいと我ながら思う。ネットワークエラーは例外として投げられる。普通に扱いにくかったため後半の章で改善した。

helpers/customErrors/refreshTokenHasExpiredError.ts
export class RefreshTokenHasExpiredError extends Error {
  constructor() {
    super('Refresh token has expired!');
    this.name = 'Refresh token has expired!';
  }
}
helpers/resource.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Auth } from 'aws-amplify';
import axios, {
  AxiosInstance,
  AxiosResponse,
  AxiosRequestConfig,
  AxiosError,
} from 'axios';
import { RefreshTokenHasExpiredError } from 'helpers/customErrors/refreshTokenHasExpiredError';
import { nanoid } from 'nanoid';

import Response, { ResponseType } from './response';

export type MyRequestConfig = {
  hostType?: string;
  isNoToken?: boolean;
} & AxiosRequestConfig;

/**
 * axios wrapper class.
 * ex)
 * const {error, data} = resource.get('/hoge', query)
 * const {error, data} = resource.post('/hoge', body)
 */
export class Resource {
  private axios: AxiosInstance;

  private responseBuilder: <T, H>(res: AxiosResponse<T>) => ResponseType<T, H>;

  constructor(
    argAxios: AxiosInstance,
    responseBuilder: <T, H>(res: AxiosResponse<T>) => ResponseType<T, H>,
  ) {
    this.axios = argAxios;
    this.responseBuilder = responseBuilder;
  }

  /**
   * axiosのエラーなのかチェック
   * @param error - エラーオブジェクト
   * @returns axiosエラーか
   */
  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  static isAxiosError(error: any): error is AxiosError {
    return !!error.isAxiosError;
  }

  /**
   * get<T:データ型, U:Param型, H:ヘッダー型>
   * @param url string
   * @param params U
   * @param options any
   * @returns
   */
  get<T = any, U = any, H = any>(
    url: string,
    params?: U,
    options?: MyRequestConfig,
  ): Promise<ResponseType<T, H>> {
    return this.request<T, H>({
      method: 'GET',
      url,
      params,
      ...options,
    });
  }

  /**
   * post<T:データ型, U:body型, H:ヘッダー型>
   * @param url string
   * @param params U
   * @param options any
   * @returns
   */
  post<T = any, U = any, H = any>(
    url: string,
    data?: U,
    options?: MyRequestConfig,
  ): Promise<ResponseType<T, H>> {
    return this.request<T, H>({
      method: 'POST',
      url,
      data,
      ...options,
    });
  }

  /**
   * put<T:データ型, U:body型, H:ヘッダー型>
   * @param url string
   * @param params U
   * @param options any
   * @returns
   */
  put<T = any, U = any, H = any>(
    url: string,
    data?: U,
    options?: MyRequestConfig,
  ): Promise<ResponseType<T, H>> {
    return this.request<T, H>({
      method: 'PUT',
      url,
      data,
      ...options,
    });
  }

  /**
   * delete<T:データ型, U:Param型, H:ヘッダー型>
   * @param url string
   * @param params U
   * @param options any
   * @returns
   */
  delete<T = any, U = any, H = any>(
    url: string,
    data?: U,
    options?: MyRequestConfig,
  ): Promise<ResponseType<T, H>> {
    return this.request<T, H>({
      method: 'DELETE',
      url,
      data,
      ...options,
    });
  }

  async request<T = any, H = any>(
    options: MyRequestConfig,
  ): Promise<ResponseType<T, H>> {
    const localOptions: MyRequestConfig = { ...options };

    if (!localOptions.isNoToken) {
      const session = await Auth.currentSession();

      // token の 有効期限に応じてrefresh
      const idTokenExpire = session.getIdToken().getExpiration();
      const refreshToken = session.getRefreshToken();
      const currentTimeSeconds = Math.round(+new Date() / 1000);
      let headers: { [key in string]: string } = {
        'X-REQUEST-ID': nanoid(),
      };
      if (idTokenExpire < currentTimeSeconds) {
        const response = await Auth.currentAuthenticatedUser();
        response.refreshSession(
          refreshToken,
          (err: string | { message: string }) => {
            if (err) {
              Auth.signOut();
              throw new RefreshTokenHasExpiredError();
            } else {
              headers = {
                ...headers,
                Authorization: `Bearer ${session.getIdToken().getJwtToken()}`,
              };
            }
          },
        );
      } else {
        headers = {
          ...headers,
          Authorization: `Bearer ${session.getIdToken().getJwtToken()}`,
        };
      }

      localOptions.headers = {
        ...localOptions.headers,
        ...headers,
      };
    } else {
      localOptions.headers = {
        ...localOptions.headers,
        'X-REQUEST-ID': nanoid(),
      };

      delete localOptions.isNoToken;
    }

    localOptions.baseURL = process.env.BASE_URL;

    const response = await this.axios.request<T>(localOptions).catch((err) => {
      if (Resource.isAxiosError(err) && typeof err.response !== 'undefined') {
        return err.response;
      }
      throw err;
    });

    return this.responseBuilder<T, H>(response);
  }
}

const client = axios.create();

export const defaultResponseBuilder = <T, H>(
  res: AxiosResponse<T>,
): ResponseType<T, H> => new Response<T, H>(res).toJSON();

export const resource = new Resource(client, defaultResponseBuilder);

helpers/response.ts
/* eslint-disable @typescript-eslint/ban-types */

/* eslint-disable @typescript-eslint/no-explicit-any */
import { AxiosResponse } from 'axios';

export type ApiError = {
  statusCode: number;
  error: string;
  message: string;
};

// 参考: https://qiita.com/suin/items/e8cf3404161cc90821d8
const isObject = (x: unknown): x is object =>
  x !== null && (typeof x === 'object' || typeof x === 'function');

const isEmptyObject = (obj: object) => Object.keys(obj).length === 0;

const convertValidOrNull = (data: unknown) => {
  if (data === null) return null;
  if (typeof data === 'undefined') return null;
  if (typeof data === 'undefined') return null;
  if (Array.isArray(data)) return data;
  if (isObject(data) && isEmptyObject(data)) return null;

  return data;
};

export type ResponseType<T, H> =
  | {
      error: true;
      data: ApiError;
      headers: H;
      status: number;
    }
  | {
      error: false;
      data: T;
      headers: H;
      status: number;
    };

/**
 * axios response
 * used by resource.ts
 */
export default class Response<T, H> {
  private rawResponse: AxiosResponse;

  private expectStatuses: number[];

  public status: number;

  public headers: any;

  public data: any;

  constructor(response: AxiosResponse<T>) {
    this.rawResponse = response;
    this.expectStatuses = [200, 204];
    this.status = 100; // initilize
    this.buildResponse();
  }

  private buildResponse() {
    const { status, headers, data } = this.rawResponse || {}; // network_errorとかの場合responseはundefinedになる
    this.status = status;
    this.headers = headers;
    // eslint-disable-next-line no-nested-ternary
    this.data = convertValidOrNull(data);
  }

  get error(): boolean {
    return !this.expectStatuses.includes(this.status);
  }

  toJSON(): ResponseType<T, H> {
    return {
      status: this.status,
      data: this.data,
      error: this.error,
      headers: this.headers,
    };
  }
}

ネットワークエラーを例外としてでなく、返り値として扱いたい

前述のresourceが結構扱いにくかった。

  • 返り値の型が扱いにくい
  • ネットワークエラーが例外として返ってくるため必ずtry-catchしないといけない

そこでさらにresourceのラッパーを作ってみた。

こんなふうに使う

const res = request.get<レスポンス型>('/hoge')('エラー文章');
if(!res.isSuccess){
  console.error(res.error) // エラー文
}

console.log(res.body);
helpers/request.ts
/* eslint-disable @typescript-eslint/no-explicit-any */

/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Logger } from '@aws-amplify/core';
import { RefreshTokenHasExpiredError } from 'helpers/customErrors/RefreshTokenHasExpiredError';
import { resource } from 'helpers/resource';

const logger = new Logger('request');

export type ResponseAPI<Body = unknown, Header = unknown> =
  | {
      isSuccess: true;
      body: Body; // これbodyがないとき、body: undefinedって指定しないといけないので不便。なにか他に方法ないのか。
      header: Header;
    }
  | {
      isSuccess: false;
      error: string;
    };

/**
 * カリー化されているので注意。
 * try-catchしなくてもよいように、返り値errorにすべてのエラーが包まれている。
 * @param url string
 * @param params RequestParam
 * @param options any
 * @returns (errorMessage: string) => Promise<ResponseAPI<ResponseBody, ResponseHeader>>
 * @example
 *   return request.get<
 *     operations['get-hoge']['responses']['200']['content']['application/json']
 *   >('/hoge')('hoge取得に失敗しました!');
 */
const get = <
  ResponseBody = unknown,
  RequestParam = unknown,
  ResponseHeader = unknown
>(
  url: string,
  params?: RequestParam,
  options?: any,
) => async (
  errorMessage: string,
): Promise<ResponseAPI<ResponseBody, ResponseHeader>> => {
  try {
    const res = await resource.get<ResponseBody, RequestParam, ResponseHeader>(
      url,
      params,
      options,
    );

    if (res.error) {
      logger.error(
        `${errorMessage} 詳細: リクエストエラー raw: ${res.data.message}`,
      );

      return {
        isSuccess: false,
        error: `${errorMessage} 詳細: ${
          res.data.message || 'リクエストエラー'
        }`,
      };
    }

    return {
      isSuccess: true,
      body: res.data,
      header: res.headers,
    };
  } catch (err) {
    logger.error(
      `${errorMessage} 詳細: ネットワークエラーが生じました raw: ${err}`,
    );

    if (err instanceof RefreshTokenHasExpiredError) {
      return {
        isSuccess: false,
        error: 'セッションの期限切れです。ログインし直してください。',
      };
    }

    if (err instanceof Error) {
      return {
        isSuccess: false,
        error: `${errorMessage} 詳細: ネットワークエラーが生じました`,
      };
    }

    return {
      isSuccess: false,
      error: `${errorMessage} 詳細: ネットワークエラーが生じました`,
    };
  }
};

/**
 * カリー化されているので注意。
 * try-catchしなくてもよいように、返り値errorにすべてのエラーが包まれている。
 * @param url string
 * @param params RequestBody
 * @param options any
 * @returns (errorMessage: string) => Promise<ResponseAPI<ResponseBody, ResponseHeader>>
 * @example
 *   request.post<operations['post-hoge']>(
      `/hoge`,
    )('hoge登録に失敗しました!');
 */
const post = <
  ResponseBody = unknown,
  RequestBody = unknown,
  ResponseHeader = unknown
>(
  url: string,
  params?: RequestBody,
  options?: any,
) => async (
  errorMessage: string,
): Promise<ResponseAPI<ResponseBody, ResponseHeader>> => {
  try {
    const res = await resource.post<ResponseBody, RequestBody, ResponseHeader>(
      url,
      params,
      options,
    );

    if (res.error) {
      logger.error(
        `${errorMessage} 詳細: リクエストエラー raw: ${res.data.message}`,
      );

      return {
        isSuccess: false,
        error: `${errorMessage} 詳細: ${
          res.data.message || 'リクエストエラー'
        }`,
      };
    }

    return {
      isSuccess: true,
      body: res.data,
      header: res.headers,
    };
  } catch (err) {
    logger.error(
      `${errorMessage} 詳細: ネットワークエラーが生じました raw: ${err}`,
    );

    if (err instanceof RefreshTokenHasExpiredError) {
      return {
        isSuccess: false,
        error: 'セッションの期限切れです。ログインし直してください。',
      };
    }

    if (err instanceof Error) {
      return {
        isSuccess: false,
        error: `${errorMessage} 詳細: ネットワークエラーが生じました`,
      };
    }

    return {
      isSuccess: false,
      error: `${errorMessage} 詳細: ネットワークエラーが生じました`,
    };
  }
};

/**
 * カリー化されているので注意。
 * try-catchしなくてもよいように、返り値errorにすべてのエラーが包まれている。
 * @param url string
 * @param params RequestBody
 * @param options any
 * @returns (errorMessage: string) => Promise<ResponseAPI<ResponseBody, ResponseHeader>>
 * @example
 *   return request.put<
      operations['put-hoge']['responses']['200'],
      operations['put-hoge']['requestBody']['content']['application/json']
    >(
      `/hoge`,
      body,
    )('hoge修正に失敗しました');
 */
const put = <
  ResponseBody = unknown,
  RequestBody = unknown,
  ResponseHeader = unknown
>(
  url: string,
  body?: RequestBody,
  options?: any,
) => async (
  errorMessage: string,
): Promise<ResponseAPI<ResponseBody, ResponseHeader>> => {
  try {
    const res = await resource.put<ResponseBody, RequestBody, ResponseHeader>(
      url,
      body,
      options,
    );

    if (res.error) {
      logger.error(
        `${errorMessage} 詳細: リクエストエラー raw: ${res.data.message}`,
      );

      return {
        isSuccess: false,
        error: `${errorMessage} 詳細: ${
          res.data.message || 'リクエストエラー'
        }`,
      };
    }

    return {
      isSuccess: true,
      body: res.data,
      header: res.headers,
    };
  } catch (err) {
    logger.error(
      `${errorMessage} 詳細: ネットワークエラーが生じました raw: ${err}`,
    );

    if (err instanceof RefreshTokenHasExpiredError) {
      return {
        isSuccess: false,
        error: 'セッションの期限切れです。ログインし直してください。',
      };
    }

    if (err instanceof Error) {
      return {
        isSuccess: false,
        error: `${errorMessage} 詳細: ネットワークエラーが生じました`,
      };
    }

    return {
      isSuccess: false,
      error: `${errorMessage} 詳細: ネットワークエラーが生じました`,
    };
  }
};

/**
 * カリー化されているので注意。
 * try-catchしなくてもよいように、返り値errorにすべてのエラーが包まれている。
 * @param url string
 * @param params RequestParam
 * @param options any
 * @returns (errorMessage: string) => Promise<ResponseAPI<ResponseBody, ResponseHeader>>
 * @example
 *   return request.delete<never>(`/hoge`)(
 *     'hogeの削除に失敗しました',
 *   );
 */
const del = <
  ResponseBody = unknown,
  RequestParam = unknown,
  ResponseHeader = unknown
>(
  url: string,
  params?: RequestParam,
  options?: any,
) => async (
  errorMessage: string,
): Promise<ResponseAPI<ResponseBody, ResponseHeader>> => {
  try {
    const res = await resource.delete<
      ResponseBody,
      RequestParam,
      ResponseHeader
    >(url, params, options);

    if (res.error) {
      logger.error(
        `${errorMessage} 詳細: リクエストエラー raw: ${res.data.message}`,
      );

      return {
        isSuccess: false,
        error: `${errorMessage} 詳細: ${
          res.data.message || 'リクエストエラー'
        }`,
      };
    }

    return {
      isSuccess: true,
      body: res.data,
      header: res.headers,
    };
  } catch (err) {
    logger.error(
      `${errorMessage} 詳細: ネットワークエラーが生じました raw: ${err}`,
    );

    if (err instanceof RefreshTokenHasExpiredError) {
      return {
        isSuccess: false,
        error: 'セッションの期限切れです。ログインし直してください。',
      };
    }

    if (err instanceof Error) {
      return {
        isSuccess: false,
        error: `${errorMessage} 詳細: ネットワークエラーが生じました`,
      };
    }

    return {
      isSuccess: false,
      error: `${errorMessage} 詳細: ネットワークエラーが生じました`,
    };
  }
};

export default {
  get,
  post,
  put,
  delete: del,
};

独自SDKを作る

requestをそのまま呼んでもいいですがモジュール化するとより扱いやすくなります。

こんなふうに使う

const res = await ChatRequest.fetch();
if(!res.isSuccess){
  console.error(res.error) // エラー文
}

console.log(res.body);
helpers/requests/ChatRequest.ts
import request, { ResponseAPI } from 'helpers/request';
import { Hoge } from 'helpers/types';
 
export class ChatRequest {
  static async fetch(): Promise<ResponseAPI<Hoge>> {
    const res = await request.get<
      Hoge,
      { query: string; }
    >('/cases/search', {
      query: 'hogehoge'
    })('チャット一覧の取得に失敗しました!');

    if (!res.isSuccess) return res;

    return {
      isSuccess: true,
      body: res.body,
      header: undefined
    };
  }
}

Discussion