Strapi のレスポンスの階層を浅くして扱いやすくする(v4の形からv3の形に変更)

以前 Strapi(v4)をクライアント側(iOS/Androidアプリ)から叩いた時に、レスポンスの階層があまりに深くてとても扱いづらくて困っていました。

これはどうにかならないのかな…と調べていたところ、「Strapi v4のAPIをカスタムして扱いやすくする | Tech Q Lab – 株式会社キューのテックブログ」にて階層を浅くする方法が書かれていました。

Strapi のバージョンが v3 -> v4 になった際にレスポンスのデータ構造が代わり、階層が深くなった背景を考えると、もしかすると非推奨かもしれません。ですが、とても扱いやすくなったので、メモしておきます。

ちなみに GraphQL を使うことは諦めました。(クライアント側で挫折)

前提

環境は「Strapi を開発環境と本番環境のDockerで動かしたい【まとめ版】」の方法にて構築しています。

やりたいこと

Strapi のレスポンスは以下のようになります。

例えば Newsという以下のようなコレクションタイプを用意しました。

Strapi v4 ではGET /newsをすると以下のようなレスポンスになります。

{
  "data": [
    {
      "id": 1,
      "attributes": {
        "title": "1つめのおしらせです",
        "content": "これは1つ目のお知らせです。\n\n",
        "createdAt": "2023-04-28T03:23:29.738Z",
        "updatedAt": "2023-04-28T04:30:04.400Z",
        "publishedAt": "2023-04-28T03:23:33.396Z",
        "locale": "en",
        "tags": {
          "data": [
            {
              "id": 2,
              "attributes": {
                "displayName": "全体のお知らせ",
                "slug": "news",
                "createdAt": "2023-04-28T04:29:49.151Z",
                "updatedAt": "2023-04-28T04:33:18.721Z",
                "publishedAt": "2023-04-28T04:33:18.716Z",
                "locale": "en"
              }
            }
          ]
        },
        "localizations": {
          "data": []
        }
      }
    },
    {
      "id": 2,
      "attributes": {
        "title": "2つ目のお知らせです",
        "content": "これは2つ目のお知らせです。\n\nブログを書いています。",
        "createdAt": "2023-04-28T03:23:53.417Z",
        "updatedAt": "2023-04-28T04:30:12.109Z",
        "publishedAt": "2023-04-28T03:23:54.351Z",
        "locale": "en",
        "tags": {
          "data": [
            {
              "id": 1,
              "attributes": {
                "displayName": "iOS用のお知らせ",
                "slug": "ios",
                "createdAt": "2023-04-28T04:29:40.998Z",
                "updatedAt": "2023-04-28T04:33:15.138Z",
                "publishedAt": "2023-04-28T04:33:15.129Z",
                "locale": "en"
              }
            }
          ]
        },
        "localizations": {
          "data": []
        }
      }
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 2
    }
  }
}

これを なるべく階層が浅くなるようにdataattributesを排除した形にしていきます。
一番上層のdata以外は消えるようにしたいです。

{
  "data": [
    {
      "id": 1,
      "title": "1つめのおしらせです",
      "content": "これは1つ目のお知らせです。\n\n",
      "createdAt": "2023-04-28T03:23:29.738Z",
      "updatedAt": "2023-04-28T04:30:04.400Z",
      "publishedAt": "2023-04-28T03:23:33.396Z",
      "locale": "en",
      "tags": [
        {
          "id": 2,
          "displayName": "全体のお知らせ",
          "slug": "news",
          "createdAt": "2023-04-28T04:29:49.151Z",
          "updatedAt": "2023-04-28T04:33:18.721Z",
          "publishedAt": "2023-04-28T04:33:18.716Z",
          "locale": "en"
        }
      ],
      "localizations": []
    },
    {
      "id": 2,
      "title": "2つ目のお知らせです",
      "content": "これは2つ目のお知らせです。\n\nブログを書いています。",
      "createdAt": "2023-04-28T03:23:53.417Z",
      "updatedAt": "2023-04-28T04:30:12.109Z",
      "publishedAt": "2023-04-28T03:23:54.351Z",
      "locale": "en",
      "tags": [
        {
          "id": 1,
          "displayName": "iOS用のお知らせ",
          "slug": "ios",
          "createdAt": "2023-04-28T04:29:40.998Z",
          "updatedAt": "2023-04-28T04:33:15.138Z",
          "publishedAt": "2023-04-28T04:33:15.129Z",
          "locale": "en"
        }
      ],
      "localizations": []
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 2
    }
  }
}

レスポンスを編集する

基本的には「Strapi v4のAPIをカスタムして扱いやすくする | Tech Q Lab – 株式会社キューのテックブログ」の記事の通りに行います。

STEP.1
レスポンスを整形するための関数を追加する

まずは、レスポンスの整形をするための関数を置くためのファイル./config/functions.tsを作成します。

# Strapi プロジェクトに移動する
$ cd ~/StrapiProjects/vps-docker-strapi

# functions.ts を作成する
$ touch config/functions.ts

まずは、find (CollectionTypeなら配列でデータを返す)をカスタマイズします。

GET http://localhost:1337/api/newsを叩いた時のレスポンスが整形されます。

./config/functions.ts
module.exports = {
  // 各Content-TypeのController から呼び出すメソッド
  convertToV3Data: () => ({
    // Core の findを上書きする
      async find(ctx) {
        ctx.query = { ...ctx.query, populate: "*" }
        const response = await super.find(ctx)
        const { data, meta } = response ?? {}
        if (data == null) {
          return response
        }
        const v3Data = toV3Data(data, ctx.query)
        return {
          data: v3Data,
          meta: meta
        }
      },
  }),
}

/**
 * strapi v4のレスポンスをv3の形に変換する
 */
const toV3Data = (data, query) => {
  if (data == null) { return null }

  // 配列の場合は1つずつ変換する
  if (data instanceof Array) {
    return data.map((d) => toV3Data(d, query)).filter((d) => d)
  }

  return {
    id: data.id,
    ...Object.entries(data.attributes).reduce((acc, [key, value]) => {
      // さらに階層が深い場合に
      if (value != null && typeof value === "object" && "data" in value) {
        // { data: {欲しいオブジェクト}} の形になっているので data から取り出しておく
        const data = value["data"]

        value = toV3Data(data, query)
      }
      return { ...acc, [key]: value }
    }, {})
  }
}

参考の記事ではmeta情報が消えてしまっていましたが、metaが残るように変更しています。

STEP.2
Content-Type の controller.ts で適用する
先ほど作ったfunctions.tsconvertToV3Dataを Content-Type のcontroller.tsで呼び出します。

./src/api/コンテンツタイプ名/controllers/コンテンツタイプ名.tsを編集します。

# Strapi プロジェクトに移動する
$ cd ~/StrapiProjects/vps-docker-strapi

# VSCodeで開く
$ code src/api/news/controllers/news.ts 

ここにあります。

├── src
│   ├── api
│   │   ├── news(コンテンツ名)
│   │   │   ├── controllers
│   │   │   │   ├── news.ts (コンテンツ名.ts)

2つ目の引数にstrapi.config.functions.convertToV3Dataを追加します。

./src/api/news/controllers/news.ts
/**
 * news controller
 */

import { factories } from '@strapi/strapi'

export default factories.createCoreController('api::news.news', strapi.config.functions.convertToV3Data);

これで、GET http://localhost:1337/api/newsを叩いた時のレスポンスが整形されます。

STEP.3
GET/POST/PUT/DELETE にも対応する

続いて、単体取得やPOST にも対応していきます。
STEP.1のfunctions.tsに追記します。

Strapi ドキュメントの Extend core controllersで、core の controllers の拡張方法が書かれているので、そこを参考に追記していきます。

functions.ts
module.exports = {
    convertToV3Data: () => ({
      async find(ctx) {
        ctx.query = { ...ctx.query, populate: "*" }
        const response = await super.find(ctx)
        const { data, meta } = response ?? {}
        if (data == null) {
          return response
        }
        const v3Data = toV3Data(data, ctx.query)
        return {
          data: v3Data,
          meta: meta
        }
      },

      async findOne(ctx) {
        if (ctx.request.url.includes("/count")) {
          const {
            meta: {
              pagination: { total },
            },
          } = await super.find(ctx);
          return total;
        }

        ctx.query = { ...ctx.query, populate: "*" }

        const response = await super.findOne(ctx)
        const { data, meta } = response ?? {}
        if (data == null) {
          return response
        }

        const v3Data = toV3Data(data, ctx.query)
        return {
          data: v3Data,
          meta: meta
        }
      },

      async create(ctx) {
        const response = await super.create(ctx)
        const { data, meta } = response ?? {}
        if (data == null) {
          return response
        }
        const v3Data = toV3Data(data, ctx.query)
        return {
          data: v3Data,
          meta: meta
        }
      },

      async update(ctx) {
        const response = await super.update(ctx)
        const { data, meta } = response ?? {}
        if (data == null) {
          return response
        }
        const v3Data = toV3Data(data, ctx.query)
        return {
          data: v3Data,
          meta: meta
        }
      },

      async delete(ctx) {
        const response = await super.delete(ctx)
        const { data, meta } = response ?? {}
        if (data == null) {
          return response
        }
        const v3Data = toV3Data(data, ctx.query)
        return {
          data: v3Data,
          meta: meta
        }
      }
    }),
 }

/**
 * strapi v4のレスポンスをv3の形に変換する
 */
const toV3Data = (data, query) => {
// .. 略
}

findOneのif文に関しましては、参考にさせていただいている記事のカウントの追加部分になります。

これで、レスポンスの整形ができました。

新規にContent-Typeを追加した時

新しく Content-Type を追加した時には毎回この Convert 関数を適用する必要があります。

Content-Type を追加したら./src/api/コンテンツタイプ名/controllers/コンテンツタイプ名.tsを編集します。

./src/api/コンテンツタイプ名/controllers/コンテンツタイプ名.ts
export default factories.createCoreController('api::コンテンツタイプ名.コンテンツタイプ名', strapi.config.functions.convertToV3Data);

ここの自動化ができたらとても便利ですが、今回の調査はここまでにします。

populate=* を設定しているのに relation が取得できない場合

今回の拡張によりpopulate=*が全てのfindで適用されるため、news-tags と紐づいているときに、tagsも取得できるようになります。population のドキュメント

しかし、populate=*を設定しているのにも関わらず、深い階層が取れない場合には、API Token の Permissions を確認してみてください。
チェックが入っていない場合には、レスポンスに含まれません。

新規にContent-Typeを追加して、上記のConvert 関数を適用してもレスポンスに含まれていない場合にはチェックが外れているかもしれません。