API関数の作成をやめよう

カバー画像 「API関数の作成をやめよう」

フロントエンドのアプリを開発していて、RESTful APIを利用するバックエンドを使っているなら、エンドポイントごとに関数を書くのはもうやめましょう!

RESTful APIは通常、異なるエンティティに対してCRUD(作成、読み取り、更新、削除)アクションを行う一連のエンドポイントを提供します。私たちは、これらのエンドポイントのそれぞれに対してプロジェクト内に関数を持っていて、これらの関数は非常に似た仕事をしますが、異なるエンティティに対して行います。例えば、次のような関数があります:

// apis/users.js

// 作成
export function createUser(userFormValues) {
  return fetch("/users", { method: "POST", body: userFormValues });
}

// 読み取り
export function getListOfUsers(keyword) {
  return fetch(`/users?keyword=${keyword}`);
}

export function getUser(id) {
  return fetch(`/users/${id}`);
}

// 更新
export function updateUser(id, userFormValues) {
  return fetch(`/users/${id}`, { method: "PUT", body: userFormValues });
}

// 削除
export function removeUser(id) {
  return fetch(`/users/${id}`, { method: "DELETE" });
}

そして同じようなセットの関数がCityProductCategoryなど他のエンティティについても存在するかもしれません。しかしこれらすべての関数を単純な関数呼び出しで置き換えることができます:

// apis/users.js
export const users = crudBuilder("/users");

// apis/cities.js
export const cities = crudBuilder("/regions/cities");

そして、このように使います:

users.create(values);
users.show(1);
users.list("john");
users.update(values);
users.remove(1);

でも、なんで?とあなたは思うかもしれません

いくつかのよい理由があります:

  • コード行の削減 - あなたが書き、他の人がメンテナンスをしなければならないコードです。
  • API関数の命名規則を強制することで、コードの読みやすさと保守性が向上します。getListOfUsersgetCitiesgetAllProductsproductIndexfetchCategoriesなど、すべて同じことをする関数名を見かけたことがあるでしょう。「エンティティのリストを取得する」ということです。このアプローチを使用すると、常にentityName.list()関数を持っていて、チームの誰もがそれを知っています。

それでは、そのcrudBuilder()関数を作成して、少し便利な機能を加えましょう。

とてもシンプルなCRUDビルダー

上記のシンプルな例で、crudBuilder関数は本当にシンプルになります:

export function crudBuilder(baseRoute) {
  function list(keyword) {
    return fetch(`${baseRoute}?keyword=${keyword}`);
  }

  function show(id) {
    return fetch(`${baseRoute}/${id}`);
  }

  function create(formValues) {
    return fetch(baseRoute, { method: "POST", body: formValues });
  }

  function update(id, formValues) {
    return fetch(`${baseRoute}/${id}`, { method: "PUT", body: formValues });
  }

  function remove(id) {
    return fetch(`${baseRoute}/${id}`, { method: "DELETE" });
  }

  return {
    list,
    show,
    create,
    update,
    remove
  }
}

APIパスの規則を前提としており、エンティティのパス接頭語を与えられたら、そのエンティティに対してCRUDアクションを呼び出すために必要なすべてのメソッドを返します。

しかし正直に言って、本物のアプリケーションはそれほどシンプルではありません!このアプローチを私たちのプロジェクトに応用する際に検討すべきたくさんのことがあります:

  • フィルタリング:リストAPIは通常、たくさんのフィルターパラメータを受け取ります
  • ページネーション:リストは常にページネーションされます
  • 変換:提供されるAPIの値は実際に使用される前に変換する必要があるかもしれません
  • 準備formValuesオブジェクトはAPIに送信される前にいくつか準備が必要です
  • カスタムエンドポイント:特定のアイテムを更新するエンドポイントは常に`${baseRoute}/${id}`というわけではありません

ですので、より複雑な状況を処理できるCRUDビルダーが必要です。

高度なCRUDビルダー

実際のプロジェクトで私たちが毎日使うものを構築してみましょう。先に挙げた問題に対処します。

フィルタリング

まず、私たちのlist関数ではもっと複雑なフィルタリングを処理することができる必要があります。それぞれのエンティティリストは異なるフィルターを持ち、ユーザーはそれらの一部を適用しているかもしれません。したがって、適用されたフィルターの形や値について何も想定できませんが、どんなリストフィルタリングも異なるフィルター名に対していくつかの値を指定するオブジェクトを生み出すと想定することはできます。たとえば、いくつかのユーザーをフィルタリングするには次のようになるかもしれません:

const filters = {
  keyword: "john",
  createdAt: new Date("2020-02-10"),
}

一方で、これらのフィルターがAPIにどのように渡されるべきかはわかりませんが、各フィルターがリストAPIに対応するパラメーターを持っていて、"key=value"の形式のURLクエリパラメータとして渡せると想定(またはAPIプロバイダと契約)できます。

したがって、適用されたフィルターを対応するAPIパラメータに変換してlist関数を作成する方法を知る必要があります。これはcrudBuilder()transformFiltersパラメータを渡すことで行うことができます。ユーザーの場合のその例は次のようになります:

function transformUserFilters(filters) {
  const params = []
  if(filters.keyword) {
    params.push(`keyword=${filters.keyword}`);
  }
  if(filters.createdAt) {
    params.push(`created_at=${dateUtility.format(filters.createdAt)}`);
  }

  return params;
}

これで、このパラメータを使ってlist関数を作成できます。

export function crudBuilder(baseRoute, transformFilters) {
  function list(filters) {
    let params = transformFilters(filters)?.join("&");
    if(params) {
      params += "?"
    }
    return fetch(`${baseRoute}${params}`);
  }
}

変換とページネーション

APIから受け取ったデータは、アプリで使えるように変換する必要があるかもしれません。たとえば、snake_caseからcamelCaseに変換する必要があったり、日付文字列をユーザータイムゾーンに変換したりすることがあります。
また、ページネーションを処理する必要があります。

APIプロバイダによって標準化された次の形式を持つすべてのページネーション付きデータを仮定しましょう:

{
  "data": [], // エンティティオブジェクトのリスト
  "pagination": {...}, // ページネーション情報
}

したがって、私たちが知る必要があるのは、単一のエンティティオブジェクトをどのように変換すればいいかです。次にリストのオブジェクトをループ処理してそれらを変換することができます。これを行うために、私たちcrudBuilderに渡すtransformEntity関数がパラメータとして必要です:

export function crudBuilder(baseRoute, transformFilters, transformEntity) {
  function list(filters) {
    const params = transformFilters(filters)?.join("&");
    return fetch(`${baseRoute}?${params}`)
      .then((res) => res.json())
      .then((res) => ({
        data: res.data.map((entity) => transformEntity(entity)),
        pagination: res.pagination,
      }));
  }
}

これでlist()関数の改造は完了です。

準備

createupdate関数については、formValuesをAPIが期待する形式に変換する必要があります。たとえば、フォームにCityオブジェクトを選択する都市選択があるとしますが、create APIはcity_idだけが必要です。ですから、次のようなことをする関数が必要になります:

const prepareValue = formValues => ({city_id: formValues.city.id}) 

この関数はプレーンオブジェクトまたは使用場合によってはFormDataを返すかもしれませんが、データをAPIに渡すために次のように使用できます:

export function crudBuilder(
  baseRoute,
  transformFilters,
  transformEntity,
  prepareFormValues
) {
  function create(formValues) {
    return fetch(baseRoute, {
      method: "POST",
      body: prepareFormValues(formValues),
    });
  }
}

カスタムエンドポイント

エンティティのいくつかのアクションに対するAPIエンドポイントが同じ規則に従わない希少な状況もあります。ユーザーを編集するために`/users/${id}`の代わりに`/edit-user/${id}`を使用する必要がある例です。これらの場合、カスタムパスを指定できるようにする必要があります。

ここではcrudビルダーで使用される任意のパスを上書きすることを許可します。show、update、removeアクションのパスはエンティティオブジェクトからの情報に依存することがありますので、パスを取得するために関数を使用し、エンティティオブジェクトを渡す必要があります。

デフォルトパスに指定されたものがない場合はフォールバックをするようなオブジェクトを取得する必要があります。例えば:

const paths = {
  list: "list-of-users",
  show: (userId) => `users/with/id/${userId}`,
  create: "users/new",
  update: (user) => `users/update/${user.id}`,
  remove: (user) => `delete-user/${user.id}`
}

最終的なCRUDビルダー

これがCRUD API関数を作成するための最終的なコードです。

export function crudBuilder(
  baseRoute,
  transformFilters,
  transformEntity,
  prepareFormValues,
  paths
) {

  function list(filters) {
    const path = paths.list || baseRoute;
    let params = transformFilters(filters)?.join("&");
    if(params) {
      params += "?"
    }
    return fetch(`${path}${params}`)
      .then((res) => res.json())
      .then((res) => ({
        data: res.data.map((entity) => transformEntity(entity)),
        pagination: res.pagination,
      }));
  }

  function show(id) {
    const path = paths.show?.(id) || `${baseRoute}/${id}`;

    return fetch(path)
      .then((res) => res.json())
      .then((res) => transformEntity(res));
  }

  function create(formValues) {
    const path = paths.create || baseRoute;

    return fetch(path, {
      method: "POST",
      body: prepareFormValues(formValues),
    });
  }

  function update(id, formValues) {
    const path = paths.update?.(id) || `${baseRoute}/${id}`;

    return fetch(path, { method: "PUT", body: formValues });
  }

  function remove(id) {
    const path = paths.remove?.(id) || `${baseRoute}/${id}`;

    return fetch(path, { method: "DELETE" });
  }

    return {
    list,
    show,
    create,
    update,
    remove
  }
}
```<br><br>こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。<br>[https://dev.to/saeedmosavat/stop-writing-api-functions-3693](https://dev.to/saeedmosavat/stop-writing-api-functions-3693)