🐷

ContentfulをCodeSandboxで試す

2023/01/04に公開

始めに

Headless CMSのサービスの一つにContentfulがあります。HeadlessなためAPI経由でデータの取得や登録を行うことができ、DBやサーバーを用意せずともデータの管理を行うことができます。
普通はContentfulにアクセスするためのSpace IDやAccess Tokenなどを.envなど環境変数に設定して動かすものですが、気軽に試せるようにCodeSandboxで動かすようなやり方にしてみましたので、その辺のやり方についてまとめてみました。

この記事で作るアプリの動き

Contentfulを使ってTODOアプリを作ります。Contentfulへのアクセスに必要なSpace IDやTokenなどをlocalStorageに保存したら、それを使って以下のCRUD機能を提供します。

  • Create(TODO作成)
  • Read(TODO一覧取得)
  • Update(TODOテキスト変更、チェック状態の変更)
  • Delete(TODO削除)


TODO作成


TODOテキスト変更

チェック状態の変更


TODO削除

Contentfulの設定

まずはこちらからContentfulのアカウントを作成します。
https://www.contentful.com/sign-up/

TODO用モデルを用意

Content typeの追加

Contentfulの管理画面に入った後、「Content model」タブを選択して「Add content type」をクリックします。

画面遷移した後、Content typeを作成するモーダルが表示されるので、以下のように入力して「Create」ボタンを押して作成します。

作成後はこのような画面になっているため、「Add field」でTODOモデルに登録できるfieldを設定していきます。

textフィールドの設定

まずはTODOのテキストを保存するfieldを用意したいので、「Add field」をクリックし、その時出てきたモーダルには「text」ボタンをクリックします。

text fieldの設定は以下のような感じに入力して、「Create and configure」をクリックします。

text fieldの詳細設定ではValidationタブところで「Required field」にチェックを入れて「Confirm」をクリックします。

チェックの設定

次にTODOが完了したかを管理するフラグを用意したいので、また「Add field」ボタンを押して、今度は「Boolean」ボタンをクリックします。

Boolean fieldの設定は以下のような感じで入力して、「Create and configure」をクリックします。

Boolean fieldの詳細設定ではtext fieldの詳細設定と同じようにValidationタブで「Required field」を入れておくと良いですが、加えてDefault valueタブでNoを入れておくと良いです。

設定内容の確定

ここまで終えると画面が以下のようになっていると思います。これで問題なければ最後に「Save」をクリックして設定内容を確定させます。

蛇足:Contentfulの管理画面からTODOを登録する場合

今回はAPIで新規作成までやるので不要ですが、一応Contentfulの管理画面から登録するやり方も載せておきます。
「Content」タブを選択して、「Add Entry」から先ほど作成したTodoモデルをクリックします。

必要事項を入力したら、「Publish」をクリックして外部からアクセスできる状態にします。

一覧に戻って上に登録されていると思います。

API経由でアクセスできるようにトークンなどを発行する

APIからアクセスするためにトークンを発行する必要があります。その設定は「Settings > API keys」でできるためまずはそこに移動します。

fetch用のトークンを発行する

API keysの画面に入ったら「Add API key」ボタンをクリックします。

クリックしたらトークン情報が見れるので、「Space ID」と「Content Delivery API - access token」をコピーしておいてください。

POST, PATCH用のトークンを発行する

今度は登録、更新するためのトークンを発行します。API keysの画面に戻って「Content management tokens」タブの方に移動し、「Generate personal token」をクリックします。

Token nameを入力してGenerateしたら以下のようにトークンが発行されるのでそれをコピーします。

すぐに削除したトークンなので見せても問題ないですが、一応黒く塗りつぶしています

API経由でContentfulにアクセスする

Contentful周りの設定が終わったのでいよいよプログラムを書いていきます。

トークン情報などを保存する機能を実装

オンラインエディタの特性上、トークンなどをそのまま入力するわけにはいかないため、まずはその辺の情報を入力するUIを用意します。以下のようなコードを書いて、localStorageに保存されるUIを作ります。localStorageに保存するキー名は定数にして適当な名前で設定します。

SettingContentful.tsx
import { FC, useState } from "react";
import {
  CONTENTFUL_SPACE_ID_STORAGE_KEY,
  CONTENTFUL_ACCESS_TOKEN_STORAGE_KEY,
  CONTENTFUL_MANAGEMENT_TOKEN_STORAGE_KEY
} from "../ContentfulStorageKey";

type Props = {
  /** ローカルストレージに保存された時のコールバック */
  onSaved: () => void;
};

export const SettingContentful: FC<Props> = (props) => {
  const [contentfulSpaceId, setContentfulSpaceId] = useState(
    localStorage.getItem(CONTENTFUL_SPACE_ID_STORAGE_KEY) || ""
  );
  const [contentfulAccessToken, setContentfulAccessToken] = useState(
    localStorage.getItem(CONTENTFUL_ACCESS_TOKEN_STORAGE_KEY) || ""
  );
  const [contentfulManagementToken, setContentfulManagementToken] = useState(
    localStorage.getItem(CONTENTFUL_MANAGEMENT_TOKEN_STORAGE_KEY) || ""
  );

  return (
    <div>
      <div>
        <label>Contentful Space Id:</label>
        <input
          type="text"
          value={contentfulSpaceId}
          onChange={(event) => {
            setContentfulSpaceId(event.currentTarget.value);
          }}
        />
      </div>
      <div>
        <label>Contentful Access Token:</label>
        <input
          type="password"
          value={contentfulAccessToken}
          onChange={(event) => {
            setContentfulAccessToken(event.currentTarget.value);
          }}
        />
      </div>
      <div>
        <label>Contentful Management Token:</label>
        <input
          type="password"
          value={contentfulManagementToken}
          onChange={(event) => {
            setContentfulManagementToken(event.currentTarget.value);
          }}
        />
      </div>
      <button
        onClick={() => {
          localStorage.setItem(
            CONTENTFUL_SPACE_ID_STORAGE_KEY,
            contentfulSpaceId
          );
          localStorage.setItem(
            CONTENTFUL_ACCESS_TOKEN_STORAGE_KEY,
            contentfulAccessToken
          );
          localStorage.setItem(
            CONTENTFUL_MANAGEMENT_TOKEN_STORAGE_KEY,
            contentfulManagementToken
          );
          props.onSaved();
        }}
      >
        保存
      </button>
    </div>
  );
};

トークン情報を元にContentfulにアクセスするためのAPIモジュールを作成

Contenfulはデータ取得用のものとデータを作成・編集などをする管理APIが存在して、今回はその両方を使用します。npmパッケージも分かれていて、それぞれ以下のような感じで呼び出します。

Contentfulにアクセスするためのモジュール呼び出し
import * as contentful from "contentful";
import * as contentfulManagement from "contentful-management";
import {
  CONTENTFUL_SPACE_ID_STORAGE_KEY,
  CONTENTFUL_ACCESS_TOKEN_STORAGE_KEY,
  CONTENTFUL_MANAGEMENT_TOKEN_STORAGE_KEY
} from "./ContentfulStorageKey";

// データ取得用の機能が入っている
const client = contentful.createClient({
  space: localStorage.getItem(CONTENTFUL_SPACE_ID_STORAGE_KEY) || "",
  accessToken:
    localStorage.getItem(CONTENTFUL_ACCESS_TOKEN_STORAGE_KEY) || ""
});

const management = contentfulManagement.createClient({
  accessToken:
    localStorage.getItem(CONTENTFUL_MANAGEMENT_TOKEN_STORAGE_KEY) || ""
});
management
  .getSpace(localStorage.getItem(CONTENTFUL_SPACE_ID_STORAGE_KEY) || "")
  .then((space) => {
    return space.getEnvironment("master");
  })
  .then((environment) => {
    // environmentに作成・編集などの機能が入っている
  }); 

contentful-managementの方が機能が多い分、Todoデータを操作するモジュールを取得するまで少しステップがあります。今回はお試しなのでハードコーディングしてますが、spaceの中にある環境をmasterで固定しています。
あとはこれらを適宜実行することでCRUDの機能は作れますが、処理が煩雑になってしまうのでラッパークラスを用意します。この際に、contentful-managementの方で使用する型はEnvironmentですが、今回の扱い方は管理APIの呼び出しとして振る舞っているので、変数名をmanagementApiにします。

ContentfulApi.ts
import { ContentfulClientApi } from "contentful";
import { Environment } from "contentful-management";

export class ContentfulApi {
  /** fetch用のContenfulクライアントAPI */
  private clientApi: ContentfulClientApi;
  /** POST, PATCH用のContentful管理API */
  private managementApi: Environment;

  constructor(clientApi: ContentfulClientApi, managementApi: Environment) {
    this.clientApi = clientApi;
    this.managementApi = managementApi;
  }
}

このAPIモジュールラッパーをトークンなどが保存されている時に作成し、存在する時にContentfulのアプリケーションを起動するようにします。

App.tsx
import { FC, useState, useEffect } from "react";
import * as contentful from "contentful";
import * as contentfulManagement from "contentful-management";
import { ContentfulApi } from "./ContentfulApi";
import { SettingContentful } from "./components/SettingContentful";
import { ContentfulApp } from "./ContentfulApp";
import {
  CONTENTFUL_SPACE_ID_STORAGE_KEY,
  CONTENTFUL_ACCESS_TOKEN_STORAGE_KEY,
  CONTENTFUL_MANAGEMENT_TOKEN_STORAGE_KEY
} from "./ContentfulStorageKey";

const App: FC = () => {
  const [contentfulApi, setContentfulApi] = useState<ContentfulApi>();

  const setupContentfulApi = () => {
    try {
      const client = contentful.createClient({
        space: localStorage.getItem(CONTENTFUL_SPACE_ID_STORAGE_KEY) || "",
        accessToken:
          localStorage.getItem(CONTENTFUL_ACCESS_TOKEN_STORAGE_KEY) || ""
      });

      const management = contentfulManagement.createClient({
        accessToken:
          localStorage.getItem(CONTENTFUL_MANAGEMENT_TOKEN_STORAGE_KEY) || ""
      });
      management
        .getSpace(localStorage.getItem(CONTENTFUL_SPACE_ID_STORAGE_KEY) || "")
        .then((space) => {
          return space.getEnvironment("master");
        })
        .then((environment) => {
          setContentfulApi(new ContentfulApi(client, environment));
        });
    } catch (err) {
      console.error(err);
    }
  };

  useEffect(() => {
    setupContentfulApi();
  }, []);

  return (
    <div>
      <SettingContentful
        onSaved={() => {
          setupContentfulApi();
        }}
      />
      {contentfulApi && (
        <>
          <hr />
          <ContentfulApp contentfulApi={contentfulApi} />
        </>
      )}
    </div>
  );
};

export default App;

ContentfulAppはまだ何も機能がないので以下のようなコードだけ書いておきます。

ContentfulApp.tsx
import { FC, useState, useEffect } from "react";
import { ContentfulApi } from "./ContentfulApi";

type Props = {
  contentfulApi: ContentfulApi;
};

export const ContentfulApp: FC<Props> = (props) => {
  return (
    <div>
      Contentful Todo App
    </div>
  );
};

CRUD機能を実装

下準備ができたので、いよいよCRUD機能を実装していきます。

Read機能を実装

まずは一覧を読み込む機能を実装します。各ファイルの差分は以下のようになります。

ContentfulApi.ts
 import { ContentfulClientApi } from "contentful";
 import { Environment } from "contentful-management";

+export type TodoEntry = {
+  text: string;
+  isDone: boolean;
+};

+export type Todo = TodoEntry & {
+  id: string;
+  createdAt: string;
+  updatedAt: string;
+};

 export class ContentfulApi {
   // 一部省略

+  async fetchTodos() {
+    const entries = await this.clientApi.getEntries<TodoEntry>({
+      content_type: "todo",
+      order: "-sys.createdAt"
+    });
+    return entries.items.map((item) => {
+      const todo: Todo = {
+        ...item.fields,
+        id: item.sys.id,
+        createdAt: item.sys.createdAt,
+        updatedAt: item.sys.updatedAt
+      };
+      return todo;
+    });
+  }
 }
ContentfulApp.tsx
 import { FC, useState, useEffect } from "react";
 import { ContentfulApi, Todo } from "./ContentfulApi";
 import { TodoItem } from "./components/TodoItem";

 type Props = {
   contentfulApi: ContentfulApi;
 };

 export const ContentfulApp: FC<Props> = (props) => {
+  const [todos, setTodos] = useState<Todo[]>([]);
+
+  const fetchAndSaveTodos = async () => {
+    const newTodos = await props.contentfulApi.fetchTodos();
+    setTodos(newTodos);
+  };
+
+  useEffect(() => {
+    fetchAndSaveTodos();
+  }, []);

   return (
     <div>
       Contentful Todo App
+      <div>
+        {todos.map((todo) => (
+          <TodoItem
+            key={todo.id}
+            todo={todo}
+          />
+        ))}
+      </div>
    </div>
  );
};

TODOの表示に使用したTodoItemコンポーネントは以下のようになります。

components/TodoItem.tsx
import { FC } from "react";
import { Todo } from "../ContentfulApi";

export type TodoItemProps = {
  todo: Todo;
};

export const TodoItem: FC<TodoItemProps> = (props) => {
  return (
    <div
      style={{ position: "relative", padding: "8px", border: "solid 1px #000" }}
    >
      <div>
        <div>
          <span>{props.todo.id}</span>
        </div>
      </div>
      <div>
        <span>{props.todo.text}</span>
      </div>
      <div style={{ fontSize: "14px", marginTop: "4px" }}>
        <span>作成日: {props.todo.createdAt}</span>
        <span>, </span>
        <span>更新日: {props.todo.updatedAt}</span>
      </div>
    </div>
  );
};

Create機能を実装

次にTODOを作成する機能を実装します。新規作成の場合はisDoneがデフォルト値で埋めてくれるのでOptionalの型を用意して、TodoEntryとプロパティがずれないように全プロパティをRequiredにする設定に変えました。また、contentful-managementのAPIは少し癖があって、fieldsに設定する際に各言語コードに応じた値をセットする必要があるため、1つ階層が増えます。デフォルトの言語コードはen-USになっているため、例えばtextプロパティを設定したい場合はtext: { "en-US": "テキスト" }みたいにする必要があります。
ちなみに、contentful-managementを使ってデータをfetchした際もこの言語コードでラップされた形式になります。contentfulの方で取得した場合はこのような煩わしいラップが存在しないため、データの取得だけこちらのモジュールを使うようにしました。
最後にこうした操作はまだ確定されていない状態なので、publish()を実行して公開状態にします。

ContentfulApi.ts
 import { ContentfulClientApi } from "contentful";
 import { Environment } from "contentful-management";

+export type TodoNew = {
+  text: string;
+  isDone?: boolean;
+};

-export type TodoEntry = {
-  text: string;
-  isDone: boolean;
-};
+export type TodoEntry = Required<TodoNew>;

 export type Todo = TodoEntry & {
   id: string;
   createdAt: string;
   updatedAt: string;
 };

 export class ContentfulApi {
   // 一部省略

+  async createTodo(todo: TodoNew) {
+    const res = await this.managementApi.createEntry("todo", {
+      fields: {
+        text: {
+          "en-US": todo.text
+        }
+      }
+    });
+    await res.publish();
+    return res.fields;
+  }
 }

これをReactの方で使うと以下のようなコードになります。API通信は基本親の方でやりたかったですが、登録完了後にテキストをクリアする処理があるため、登録処理をHandlerとして渡して、子コンポーネント側で実行しています。create処理なのに、そのメソッドの中に一覧更新APIも呼んでしまっているのが気になりますが止む無しです。

ContentfulApp.tsx
 import { FC, useState, useEffect } from "react";
 import { ContentfulApi, Todo } from "./ContentfulApi";
+import { TodoForm } from "./components/TodoForm";
 import { TodoItem } from "./components/TodoItem";

 type Props = {
   contentfulApi: ContentfulApi;
 };

 export const ContentfulApp: FC<Props> = (props) => {
   // 一部省略

   return (
     <div>
       Contentful Todo App
+      <TodoForm
+        createTodoHanlder={async (creatingTodo) => {
+          await props.contentfulApi.createTodo(creatingTodo);
+          // 作成したらついでに一覧も更新する
+          // Contentful側の反映が遅いのでちょっと待ってからリクエストを送る
+          setTimeout(() => {
+            fetchAndSaveTodos();
+          }, 100);
+        }}
+      />
       {/* 一部省略 */}
     </div>
   );
 };
components/TodoForm.tsx
import { FC, useState } from "react";
import { TodoNew } from "../ContentfulApi";

export type TodoFormProps = {
  createTodoHanlder: (creatingTodo: TodoNew) => Promise<void>;
};

export const TodoForm: FC<TodoFormProps> = (props) => {
  const [text, setText] = useState("");
  const [isCreating, setIsCreating] = useState(false);

  return (
    <form
      onSubmit={async (event) => {
        event.preventDefault();

        try {
          setIsCreating(true);
          await props.createTodoHanlder({
            text
          });
          setText("");
        } finally {
          setIsCreating(false);
        }
      }}
    >
      <input
        type="text"
        value={text}
        disabled={isCreating}
        onChange={(event) => {
          setText(event.currentTarget.value);
        }}
      />
      <button type="submit" disabled={isCreating || text === ""}>
        {isCreating ? "作成中..." : "作成"}
      </button>
    </form>
  );
};

Update, Delete機能を実装

最後に更新と削除機能を実装します。更新は既存のEntryに対してfieldを直接変更して、それをupdate実行することで更新されます。作成時と同様にpublishを実行することで確定するので、それも忘れずに実行します。削除はいきなりすることができないため、unpublishしてからdelete実行しています。

ContentfulApi.ts
 // 一部省略

 export class ContentfulApi {
   // 一部省略

+  async updateTodo(todoId: string, updatingTodo: TodoEntry) {
+    const todoEntry = await this.managementApi.getEntry(todoId);
+    todoEntry.fields = Object.assign(
+      {},
+      ...Object.keys(todoEntry.fields).map((key) => ({
+        [key]: {
+          "en-US": updatingTodo[key as keyof TodoEntry]
+        }
+      }))
+    );
+    const res = await todoEntry.update();
+    await res.publish();
+    return res.fields;
+  }

+  async deleteTodo(todoId: string) {
+    const todoEntry = await this.managementApi.getEntry(todoId);
+    await todoEntry.unpublish();
+    // publish状態だと削除ができないため、一度unpublishにしてからdeleteを実行する
+    await todoEntry.delete();
+  }
 }

React側のコードは以下のようになります。作成時と違って子コンポーネント側でAPIリクエスト完了後に何かすることがないためon~イベント実行処理内容を完全に親側に委ねる形にしています。

ContentfulApp
 // 一部省略

 export const ContentfulApp: FC<Props> = (props) => {
   const [todos, setTodos] = useState<Todo[]>([]);
+  const [updatingTodo, setUpdatingTodo] = useState<Todo | null>(null);
+  const [deletingTodo, setDeletingTodo] = useState<Todo | null>(null);

   // 一部省略

   return (
     <div>
       {/* 一部省略 */}
       <div>
         {todos.map((todo) => (
           <TodoItem
             key={todo.id}
             todo={todo}
+            isUpdating={updatingTodo?.id === todo.id}
+            isDeleting={deletingTodo?.id === todo.id}
+            onUpdateTodo={async (targetTodo) => {
+              try {
+                setUpdatingTodo(targetTodo);
+                await props.contentfulApi.updateTodo(targetTodo.id, targetTodo);
+                // Contentful側の反映が遅いのでちょっと待ってからリクエストを送る
+                setTimeout(() => {
+                  fetchAndSaveTodos();
+                }, 100);
+              } finally {
+                setUpdatingTodo(null);
+              }
+            }}
+            onDeleteTodo={async (targetTodo) => {
+              try {
+                setDeletingTodo(targetTodo);
+                await props.contentfulApi.deleteTodo(targetTodo.id);
+                // Contentful側の反映が遅いのでちょっと待ってからリクエストを送る
+                setTimeout(() => {
+                  fetchAndSaveTodos();
+                }, 100);
+              } finally {
+                setDeletingTodo(null);
+              }
+            }}
           />
         ))}
       </div>
     </div>
   );
 };
components/TodoItem.tsx
 import { FC } from "react";
 import { Todo } from "../ContentfulApi";

 export type TodoItemProps = {
   todo: Todo;
+  isUpdating: boolean;
+  isDeleting: boolean;
+  onUpdateTodo: (updatingTodo: Todo) => void;
+  onDeleteTodo: (deletingTodo: Todo) => void;
 };

 export const TodoItem: FC<TodoItemProps> = (props) => {
   return (
     <div
       style={{ position: "relative", padding: "8px", border: "solid 1px #000" }}
+      onClick={() => {
+        props.onUpdateTodo({
+          ...props.todo,
+          isDone: !props.todo.isDone
+        });
+      }}
     >
+      {(props.isUpdating || props.isDeleting) && (
+        <div
+          style={{
+            position: "absolute",
+            display: "flex",
+            alignItems: "center",
+            justifyContent: "center",
+            top: 0,
+            left: 0,
+            width: "100%",
+            height: "100%",
+            backgroundColor: "rgba(255, 255, 255, 0.5)"
+          }}
+          onClick={(event) => {
+            event.stopPropagation();
+          }}
+        >
+          {props.isUpdating ? "更新中..." : "削除中..."}
+        </div>
+      )}
-      <div>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
         <div>
+          <input type="checkbox" checked={props.todo.isDone} readOnly />
           <span>{props.todo.id}</span>
         </div>
+        <button
+          disabled={props.isDeleting}
+          onClick={(event) => {
+            event.preventDefault();
+            event.stopPropagation();
+            const result = window.confirm("削除してもよろしいですか?");
+            if (result) {
+              props.onDeleteTodo(props.todo);
+            }
+          }}
+        >
+          削除
+        </button>
       </div>
       <div>
         <span>{props.todo.text}</span>
+        <button
+          style={{ marginLeft: "4px" }}
+          onClick={(event) => {
+            event.preventDefault();
+            event.stopPropagation();
+            const text = window.prompt("TODOテキストの変更", props.todo.text);
+            if (text) {
+              props.onUpdateTodo({
+                ...props.todo,
+                text
+              });
+            }
+          }}
+        >
+          テキスト変更
+        </button>
       </div>
       {/* 一部省略 */}
     </div>
   );
 };

終わりに

以上がContentfulをCodeSandbox上でやるやり方でした。気軽に触れるようにするためにCodeSandboxで試してみましたが、Contentful側でモデルを用意したりAPIキーを作成したりと事前準備がそれなりに必要なので結局気軽には触れないんじゃないかなと思ってきました(汗)。データ登録とかが関わってくるものに気軽さはあまり求められないのかなぁと感じました。
一応CodeSandboxはこちらに置きますので、興味がある方はご参照ください。

また、続編としてContentfulをreact-queryで呼び出した版を記事にしましたので興味がある方はこちらもご覧ください。
https://zenn.dev/wintyo/articles/1bab62b52749c3

Discussion