🥏
TypeScript 型安全かつ扱いやすいAPIリクエストクライアントを作る
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