iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🥏

Building a Type-Safe and Easy-to-Use API Request Client with TypeScript

に公開

The axios version is 0.19. It's a bit old.

Wrapping axios

I don't remember exactly when, but I found an article about an axios wrapper somewhere and implemented it by following that. I can't find it anymore even if I look for it...

What is being done in resource:

  • Token checking with Amplify's Auth library
  • Assigning response types, header types, and request body/param types for each HTTP method
  • Putting the response into a response class

What is being done in response:

  • Error if the status code is anything other than 200 or 204
  • Handling all return values with ResponseType

It is used like this:

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

The error variable contains a boolean, and data contains the response. I personally think it's a bit hard to handle a boolean value for a variable named error. Network errors are thrown as exceptions. Since it was generally cumbersome to use, I improved it in a later section.

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;
  }

  /**
   * Check if it is an axios error
   * @param error - error object
   * @returns whether it is an axios error
   */
  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  static isAxiosError(error: any): error is AxiosError {
    return !!error.isAxiosError;
  }

  /**
   * get<T: data type, U: Param type, H: header type>
   * @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: data type, U: body type, H: header type>
   * @param url string
   * @param data 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: data type, U: body type, H: header type>
   * @param url string
   * @param data 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: data type, U: Param type, H: header type>
   * @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();

      // refresh depending on token expiration
      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;
};

// Reference: 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; // initialize
    this.buildResponse();
  }

  private buildResponse() {
    const { status, headers, data } = this.rawResponse || {}; // response will be undefined in cases like network_error
    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,
    };
  }
}

Handling Network Errors as Return Values Instead of Exceptions

The aforementioned resource was quite difficult to handle.

  • The return value type is hard to work with.
  • Network errors are returned as exceptions, requiring mandatory try-catch blocks.

So, I tried creating an additional wrapper for resource.

It is used like this:

const res = request.get<ResponseType>('/hoge')('Error message');
if(!res.isSuccess){
  console.error(res.error) // Error message
}

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; // This is inconvenient because when there's no body, you have to specify body: undefined. Is there any other way?
      header: Header;
    }
  | {
      isSuccess: false;
      error: string;
    };

/**
 * Note that this is curried.
 * All errors are wrapped in the return value's error property so that try-catch is not necessary.
 * @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')('Failed to get 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} Details: Request error raw: ${res.data.message}`,
      );

      return {
        isSuccess: false,
        error: `${errorMessage} Details: ${
          res.data.message || 'Request error'
        }`,
      };
    }

    return {
      isSuccess: true,
      body: res.data,
      header: res.headers,
    };
  } catch (err) {
    logger.error(
      `${errorMessage} Details: A network error occurred raw: ${err}`,
    );

    if (err instanceof RefreshTokenHasExpiredError) {
      return {
        isSuccess: false,
        error: 'Session has expired. Please log in again.',
      };
    }

    if (err instanceof Error) {
      return {
        isSuccess: false,
        error: `${errorMessage} Details: A network error occurred`,
      };
    }

    return {
      isSuccess: false,
      error: `${errorMessage} Details: A network error occurred`,
    };
  }
};

/**
 * Note that this is curried.
 * All errors are wrapped in the return value's error property so that try-catch is not necessary.
 * @param url string
 * @param params RequestBody
 * @param options any
 * @returns (errorMessage: string) => Promise<ResponseAPI<ResponseBody, ResponseHeader>>
 * @example
 *   request.post<operations['post-hoge']>(
      `/hoge`,
    )('Failed to register 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} Details: Request error raw: ${res.data.message}`,
      );

      return {
        isSuccess: false,
        error: `${errorMessage} Details: ${
          res.data.message || 'Request error'
        }`,
      };
    }

    return {
      isSuccess: true,
      body: res.data,
      header: res.headers,
    };
  } catch (err) {
    logger.error(
      `${errorMessage} Details: A network error occurred raw: ${err}`,
    );

    if (err instanceof RefreshTokenHasExpiredError) {
      return {
        isSuccess: false,
        error: 'Session has expired. Please log in again.',
      };
    }

    if (err instanceof Error) {
      return {
        isSuccess: false,
        error: `${errorMessage} Details: A network error occurred`,
      };
    }

    return {
      isSuccess: false,
      error: `${errorMessage} Details: A network error occurred`,
    };
  }
};

/**
 * Note that this is curried.
 * All errors are wrapped in the return value's error property so that try-catch is not necessary.
 * @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,
    )('Failed to update 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} Details: Request error raw: ${res.data.message}`,
      );

      return {
        isSuccess: false,
        error: `${errorMessage} Details: ${
          res.data.message || 'Request error'
        }`,
      };
    }

    return {
      isSuccess: true,
      body: res.data,
      header: res.headers,
    };
  } catch (err) {
    logger.error(
      `${errorMessage} Details: A network error occurred raw: ${err}`,
    );

    if (err instanceof RefreshTokenHasExpiredError) {
      return {
        isSuccess: false,
        error: 'Session has expired. Please log in again.',
      };
    }

    if (err instanceof Error) {
      return {
        isSuccess: false,
        error: `${errorMessage} Details: A network error occurred`,
      };
    }

    return {
      isSuccess: false,
      error: `${errorMessage} Details: A network error occurred`,
    };
  }
};

/**
 * Note that this is curried.
 * All errors are wrapped in the return value's error property so that try-catch is not necessary.
 * @param url string
 * @param params RequestParam
 * @param options any
 * @returns (errorMessage: string) => Promise<ResponseAPI<ResponseBody, ResponseHeader>>
 * @example
 *   return request.delete<never>(`/hoge`)(
 *     'Failed to delete 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} Details: Request error raw: ${res.data.message}`,
      );

      return {
        isSuccess: false,
        error: `${errorMessage} Details: ${
          res.data.message || 'Request error'
        }`,
      };
    }

    return {
      isSuccess: true,
      body: res.data,
      header: res.headers,
    };
  } catch (err) {
    logger.error(
      `${errorMessage} Details: A network error occurred raw: ${err}`,
    );

    if (err instanceof RefreshTokenHasExpiredError) {
      return {
        isSuccess: false,
        error: 'Session has expired. Please log in again.',
      };
    }

    if (err instanceof Error) {
      return {
        isSuccess: false,
        error: `${errorMessage} Details: A network error occurred`,
      };
    }

    return {
      isSuccess: false,
      error: `${errorMessage} Details: A network error occurred`,
    };
  }
};

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

Creating a Custom SDK

You can call request directly, but it becomes easier to handle if you modularize it.

It is used like this:

const res = await ChatRequest.fetch();
if(!res.isSuccess){
  console.error(res.error) // Error message
}

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'
    })('Failed to fetch chat list!');

    if (!res.isSuccess) return res;

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

Discussion