ContentfulにAPI経由でメディアをアップロードして公開する

この記事は「[つながる勉強会 Advent Calendar 2022](https://adventar.org/calendars/7752)」の 3日目の記事です。

https://adventar.org/calendars/7752

---

1日目の記事はこちら:point_right:「[フルリモートで働くうえで”テキストコミュニケーション”で気をつけていること](https://yutaro-blog.net/2022/12/01/text-communication/)」
2日目の記事はこちら:point_right_tone1:「[生産性を爆上げするおすすめツール達](https://zenn.dev/yuto76/articles/recommendation-tool)」

---

フロントエンドエンジニア兼デザイナーのりょーた（[@RyoTa___0222](https://twitter.com/RyoTa___0222)）です。
神戸（たまに大阪）で[つながる勉強会](https://discreet-cause-4e3.notion.site/510afcfd8862449eb24521b3b7d8fedf)というオフラインの勉強会の運営をしています。

本記事では、勉強会のコミュニティで利用するWebアプリケーションにおいて、ContentfulというHeadless CMSにAPI経由でメディア（画像やドキュメントなど）をアップロードする機能を実装したため、その手順をまとめます。作成しているアプリケーションの概要については最後に記載しておりますので興味のある方はぜひ最後までお読みください:v::v_tone1::v_tone2::v_tone3::v_tone4::v_tone5:


## 環境 / ライブラリ情報

### 環境
Node.jsで実装をしています。

```bash
> node -v
v16.17.1
```

### ライブラリ情報
Contentful内のコンテンツを更新するために、[Contentful Management API](https://www.contentful.com/developers/docs/references/content-management-api/)を利用します。
公式で[ライブラリ](https://github.com/contentful/contentful-management.js)が公開されているのでそちらをインストールしてください。

```json
"contentful-management": "^10.21.4"
```

## 実装方針
大まかな手順は以下の通りです。

1. 対象のファイルデータがBase64にエンコードされた状態であれば一時ファイルを作成（パスに存在するファイルの場合はスキップ）
1. 対象のファイルを読み込み
1. Contentfulに対象のファイルをアップロード
1. アップロードしたファイルを元にContentfulでアセットを作成し公開
1. 一時ファイルの削除（1.で作成していない場合はスキップ）

実装する上で詰まった点を説明していきたいと思います。

### 1. 対象のファイルデータがBase64にエンコードされた状態であれば一時ファイルを作成
「アプリケーション上で、ユーザーがアップロードしたファイルを元に、公開する」といったケースの場合、Base64にエンコードされたデータとしてプログラム上保有していると思いますが、このままContentfulにアップロードしても、空のファイルが作成されてしまいます。そのため、一度、以下のようにファイルを作成する必要があります。

```javascript
fs.writeFileSync(`./${ここにファイル名}`, `${Base64エンコードされたデータ}`, {
    encoding: "base64",
});
```

### 4. アップロードしたファイルを元にContentfulでアセットを作成し公開
Contentulには **"Asset"** という概念があります。アセットはメディアファイルを格納する箱のようなもので、Contentful上ではこのアセットを通して、参照、データの削除、更新などの処理が行われます。
そのため、3. でファイルをアップロードしただけだと、そういった処理ができないため、アセットを作成し紐付ける必要があります。

## ソースコード
適宜必要な箇所だけ参照ください:sunglasses:

```typescript
import { MIME_TYPES } from "@mantine/dropzone";
import { Asset } from "contentful-management";
import fs from "fs";
import path from "path";
import { createClient } from "contentful-management";

const client = createClient({
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN as string,
});

const TMP_FILE_NAME = "tmp";

export const CONTENTFUL_BASE_INFO = {
  space: "",
  environment: "master",
};

export interface Params {
  data: string;
  extension: keyof typeof MIME_TYPES;
  title: string;
}

/**
 * contentfulへファイルアップロード
 *
 * @param {Params} params
 * @returns {Promise<Asset>}
 */
export const uploadFileToContentful = async (
  params: Params
): Promise<Asset> => {
  const { data, extension, title } = params;
  const mimeType = MIME_TYPES[params.extension];
  const tmpFileName = `${TMP_FILE_NAME}.${extension}`
  const fileName = `${title}.${extension}`
  // 1. Base64にエンコードされたデータから一時ファイルを作成
  fs.writeFileSync(`./public/${tmpFileName}`, data, {
    encoding: "base64",
  });
  // 2. 対象のファイルを読み込み
  const filePath = path.resolve("./public", tmpFileName);
  const tmpFile = fs.readFileSync(filePath);
  // 3. Contentfulに対象のファイルをアップロード
  const upload = await client
    .getSpace(CONTENTFUL_BASE_INFO.space)
    .then((space) => space.getEnvironment(CONTENTFUL_BASE_INFO.environment))
    .then((environment) =>
      environment.createUpload({
        file: tmpFile,
      })
    )
    .then((upload) => {
      return upload;
    });
  // 4. アップロードしたファイルを元にContentfulでアセットを作成し公開
  const asset = await client
    .getSpace(CONTENTFUL_BASE_INFO.space)
    .then((space) => space.getEnvironment(CONTENTFUL_BASE_INFO.environment))
    .then((environment) => {
      return environment
        .createAsset({
          fields: {
            title: {
              "en-US": title,
            },
            file: {
              "en-US": {
                contentType: mimeType,
                fileName: `${fileName}`,
                uploadFrom: {
                  sys: {
                    type: "Link",
                    linkType: "Upload",
                    id: upload.sys.id,
                  },
                },
              },
            },
          },
        })
        .then((asset) => {
          return asset.processForAllLocales({ processingCheckWait: 2000 });
        })
        .then((asset) => {
          return asset.publish();
        })
        .then((asset) => {
          return asset;
        });
    });
  // 5. 一時ファイルの削除
  fs.unlink(`./public/${tmpFileName}`, (err) => {
    if (err) throw err;
  });
  return asset;
};

```

補足を書いていきます:ok_woman_tone1:
### access tokenについて
こちらはpersonal access tokenを管理画面上で発行しています（[参照](https://www.contentful.com/developers/docs/references/authentication/)）

### アップロードするファイルの拡張子について
アセットを作成する際に、ファイルのコンテンツタイプを指定する必要があるのですが、として私のケースだとアップロードするファイルの形式を予め限定できなかったので、プログラム上で取得するようにしています。
このアプリケーションではUIフレームワークに[Mantine](https://mantine.dev/)を利用しており、その中でMIMEタイプの一覧を定義してあるので、それを利用しています。

```typescript
export declare const MIME_TYPES: {
    readonly png: "image/png";
    readonly gif: "image/gif";
    readonly jpeg: "image/jpeg";
    readonly svg: "image/svg+xml";
    readonly webp: "image/webp";
    readonly mp4: "video/mp4";
    readonly zip: "application/zip";
    readonly csv: "text/csv";
    readonly pdf: "application/pdf";
    readonly doc: "application/msword";
    readonly docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
    readonly xls: "application/vnd.ms-excel";
    readonly xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
    readonly ppt: "application/vnd.ms-powerpoint";
    readonly pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation";
    readonly exe: "application/vnd.microsoft.portable-executable";
};

```

Mantineを利用していない方は、[よくある MIME タイプ](https://developer.mozilla.org/ja/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)を参考にしてみてください。（Mantineめっちゃ便利ですよおすすめです:clap:）

実装手順の説明は以上となります:raised_hands:


## 参考文献
https://www.contentfulcommunity.com/t/how-to-upload-a-base64-or-binary-image/5689

## 作成しているアプリケーションの概要
つながる勉強会では、Slackをコミュニティのスペースとして活用していたのですが、Freeプランでは90日を経過したログが削除されるようになりました。
そのため、資産として削除されたくないデータは外部に移行しようということになり、その取り組みの１つとして、LTの登壇資料を閲覧 / アップロードできるアプリケーションの作成を行っています。

技術的な内容としては、Slack認証を実装しており、Slackに招待されたユーザーのみがアップロードされたLT資料を閲覧することが可能です。また、データの管理については、Firebase Cloud Firestoreを利用しており、メディアファイルのみContentfulにアップロードしています。

:::note info
開発、運用コストは永遠0円
:::

をモットーに必要なアプリケーションを開発していく予定なので、今後も技術的知見が溜まったら共有していきたいと思います。

また、基本的には認証が必要なアプリケーションになるかと思いますが、勉強会内で利用しているタイマーアプリは、誰でも利用可能となっていますのでぜひご覧ください。

https://tsunagaru-timer.ryotanny.com/

この記事を読んで興味をもたれた方がいらっしゃいましたらぜひご参加お待ちしております:blush:

## 終わりに
Contentfulは本来コンテンツ管理のサービスですが、このような変化球な使い方も無料である程度できるのは便利ですね:ok_hand:
Management API関連の記事だと日本語のものが少なかったので、また機会があればまとめていきたいと思います:wave: