👮

[Amplify]API(REST)をフロントで呼び出すときに型安全にしたい

2023/08/28に公開

まえおき

先日バックエンドの構成をAmplify GraphqlからAmplify API(Rest)に変更したのですが、なんだか型安全に呼び出してみたいと思いました。

Graphqlの時はAmplifyが用意しているライブラリを使って↓こんな感じで型を指定することができました(<GraphQLQuery<CreateTaskMutation>>の部分)。

import { API } from 'aws-amplify'

await API.graphql<GraphQLQuery<CreateTaskMutation>>({
    authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS,
    query: createTask,
    variables,
  })

これによりリクエスト時のvariablesとレスポンスの型がセットされるので型補完が効き実装が簡単だったのですが、amplify が用意している同じ API ライブラリの RestAPIの方は同じような書き方をすることができず不便だと感じたので自作することにしました。

Amplify Rest Apiのドキュメントはこちらになります。

実際の使い方は以下のイメージです。

import { API } from 'aws-amplify'

export const fetchItems = () => {
  const apiName = 'MyApiName'
  const path = '/items'
  const myInit = {
    headers: {}, // OPTIONAL
    response: true, // OPTIONAL (return the entire Axios response object instead of only response.data)
    queryStringParameters: {
      name: 'param', // OPTIONAL
    },
  }

  return API.get(apiName, path, myInit)
    .then((response) => response)
    .catch((error) => {
      console.log(error.response)
    })
}

get メソッドのドキュメントを見に行くと下記のようになっています。

    /**
     * Make a GET request
     * @param apiName - The api name of the request
     * @param path - The path of the request
     * @param [init] - Request extra params
     * @return A promise that resolves to an object with response status and JSON data, if successful.
     */
    get(apiName: string, path: string, init: {
        [key: string]: any;
    }): Promise<any>;
    /**

リクエストパラメータなどを指定するinit部分とレスポンスの型を定義するPormise<>の部分でanyが使われていることが気に食わないですね(^^)

なんでも設定していいってなっちゃうのでリクエストパラメータの設定時やレスポンスの型をあれこれするときに気づかぬうちにミスが起きがちですww

やりたいこと

まえおきが長くなってしまいましたがやりたいこととしては、
↓こんな感じでRequestResponseのスキーマを指定すると、バリデーションが効く感じにしたいと思いました。(「まえおき」で述べたgraphqlの利用イメージを踏襲です)

また、今回は以下の要件を満たすラッパー関数を作成して実現します。

  • aws-amplifyライブラリが提供しているAPIライブラリと同じように使いたい
     → 例)api.get(xxx,xxx,xxx), api.post(xxx,xxx,xxx)
  • 最低限のAPIメソッドを使えるようにしたい
     → 今回は get, post, patch, del のみを用意(上書き)しました。これで十分と思ったためです。

できあがったもの

// RestApi.ts

import { API } from 'aws-amplify'
import { AxiosError } from 'axios'

// 特に何もしてないので、プロジェクトに合わせて好きな形に合わせてください
const errorHandler = (e: AxiosError) => {
  throw e
}

type Params = {
  /**
   * body
   */
  body?: { [key: string]: any }
  /**
   * queryStringParameters
   */
  queryStringParameters?: { [key: string]: any }
}

type DefaultParams = {
  body: { [key: string]: any }
  queryStringParameters: { [key: string]: any }
}

type ApiInit<T extends Params | undefined = DefaultParams> = {
  body?: T extends Params ? T['body'] : undefined
  queryStringParameters?: T extends Params
    ? T['queryStringParameters']
    : undefined
  headers?: Record<string, unknown>
  responseType?: 'document' | 'json' | 'text' | 'blob' | 'arrayBuffer'
}

type RestApiRequestResponse = {
  req?: Params
  res: any
}

export const RestApi = {
  get: <T extends RestApiRequestResponse>(
    apiName: string,
    path: string,
    init: ApiInit<T['req']>
  ) =>
    API.get(apiName, path, init).catch(errorHandler) as Response<
      T['res'] | null
    >,

  post: <T extends RestApiRequestResponse>(
    apiName: string,
    path: string,
    init: ApiInit<T['req']>
  ) =>
    API.post(apiName, path, init).catch(errorHandler) as Promise<
      T['res'] | null
    >,

  patch: <T extends RestApiRequestResponse>(
    apiName: string,
    path: string,
    init: ApiInit<T['req']>
  ) =>
    API.patch(apiName, path, init).catch(errorHandler) as Promise<
      T['res'] | null
    >,

  del: <T extends RestApiRequestResponse>(
    apiName: string,
    path: string,
    init: ApiInit<T['req']>
  ) =>
    API.del(apiName, path, init).catch(errorHandler) as Promise<
      T['res'] | null
    >,
}

コードをそのまま利用してジェネリクス型を使ってリクエストとレスポンスの型を厳密にしているだけです。

使い方は以下のようになります。
(何らかのアイテムを作成するAPI(createItemApi)を例に実装してみます。)

import { RestApi } from '@/repositories/RestApi'
import { ApiResponse } from '@/repositories/types/ApiResponse'

const apiName = 'MyApiName'
const path = '/items'

/** リクエストパラメータ */
type CreateItemRequestBody = {
  title: string
}

/** レスポンスの型 */
type CreateItemResponseSchema = {
  id: string
  title: string
  createdAt: string
  updatedAt: string
}

/** リクエストとレスポンスの型を作成 */
type RequestResponse = {
  req: { body: CreateItemRequestBody }
  res: ApiResponse<CreateItemResponseSchema>
}
type Props = CreateItemRequestBody
export const createItemApi = async (props: Props) => {
  const { title } = props
  
  // RequestResponseを渡す   ↓
  return await RestApi.post<RequestResponse>(apiName, path, {
    body: { title, description: 'test' },
  }).then((res) => res.data.name)
}

解説

読めばわかるかもしれませんが少しずつ解説していきます。

まずリクエストパラメータとレスポンスのスキーマをtypeで定義します。

/** リクエストパラメータ */
type CreateItemRequestBody = {
  title: string
}

/** レスポンスの型 */
type CreateItemResponseSchema = {
  id: string
  title: string
  createdAt: string
  updatedAt: string
}

こちらの値を後ほどセットして型の安全性を保つことができます。
実際にはswaggerのymlファイルから型を自動生成したりしている思うので外部ファイルから型をimportするのが一般的かと思います。

次にtype RequestResponseについてです。

/** リクエストとレスポンスの型を作成 */
type RequestResponse = {
  req: { body: CreateItemRequestBody }
  res: ApiResponse<CreateItemResponseSchema>
}

こちらは今回作成したaws-amplifyのAPIライブラリのラッパー関数であるRestApiにセットする型になります。
こちらにリクエストパラメータレスポンススキーマをセットします。

  • req(リクエストパラメータ)はオプショナル
  • res(レスポンススキーマ)は必須

にしています。

今回はpostメソッドなのでbodyパラメータを指定していますが、クエリパラメータを指定する場合はqueryStringParametersにセットします。
以下のようになります。

type RequestResponse = {
-  req: { body: CreateItemRequestBody }
+  req: { queryStringParameters: ListItemsRequestParams }
  res: ApiResponse<CreateItemResponseSchema>
}

最後にリクエスト部分です。

  return await RestApi.post<RequestResponse>(apiName, path, {
    body: { title },
  })

RestApi.post<>()に<RequestResponse>を付けることにより
リクエストパラメータとレスポンススキーマをセットさせて、型安全にしています。

利用する際に、間違ったキーを指定してみると以下のようにアラートが出ます。
(↓リクエストパラメータを間違えてセットしているとき)


(↓レスポンスをあれこれするときに存在しないキーを操作しようとしているとき)

まとめ

良い感じに型補完が効くようになりましたね!
これでリクエストパラメータの指定間違いやレスポンスデータに対しての間違った操作をすることを抑えられるはず、、、!!!

もしかしたら、こんなことをしなくてもd.tsファイルを用意すれば大丈夫かもしないですが、まだ勉強中なので今回はラッパー関数で実装してみました。
また別の良いやり方が見つかれば更新します

Discussion