Contentfulの新機能Functionsでバックエンド処理をカスタマイズする

Contentfulの新機能Functionsでバックエンド処理をカスタマイズする

Clock Icon2024.10.16

ベルリンオフィスの小西です。

ヘッドレスCMSのContentfulで現在Betaとして利用可能な Functions 機能を触ってみました。

Contentfulカスタマイズ勢としては待ち望んでいた機能ですので、本記事ではその概要とハンズオンを紹介します。

Contentful Functionsとは

Contentfulのインフラストラクチャ上で動作するサーバーレスワークロードです。独自のサーバーを用意することなく、CMSダッシュボードをカスタマイズして機能を拡張することができます。

例えば下記のようなことが可能になります。

  • Contentfulで発生するイベント(記事の保存/公開/非公開/...)をフィルタリング、変換、処理する。
  • Contentful内のデータと外部のデータベース/APIを統合し、GraphQL APIを通じて取得できるようにする
  • フロントエンドやサードパーティアプリによってトリガーされるアクションを処理するバックエンドコンポーネントをCMSに追加する

これまでも、外部でワークロードやアプリケーションをホストすることで上記一部はカバーできていましたが、今後はContentful単体で実現できる ようになり、障害点も減らすことができます。

なおいくつか制限があり、重すぎる処理は任せられない点に注意してください。

  • Functionの最大実行時間は 5 秒
  • 関数がタイムアウトした場合、再試行は行われない
  • 機能はプライベートアプリでのみ利用可能
  • 1アプリごとに Functions は 50 個まで

ハンズオン

では実際にFunctionを作成・デプロイしてみます。

今回は試しに「記事が公開されたら自動でタグを付与するバックエンドアプリ」を作成します。

CMS側のタグの作成

先にCMS側で、タグを作成しておきます。

Screenshot 2024-10-16 at 13.37.41

Appの新規作成、ビルド、デプロイ

サンプルアプリケーションを立ち上げます。

% npx create-contentful-app@latest my-app --function appevent-handler

アプリを見ると下記の構成になっているかと思います。

.
├── functions // バックエンドコードの格納先
├── public
├── src
    ├── components
    ├── hooks
    └── locations //フロントエンドコードの格納先
└ contentful-app-manifest.json // アプリケーションの設定や権限を定義するマニフェストファイル

まずはApp定義を行います。

% cd my-app
% npm run create-app-definition

? App name (my-app): my-app
? Select where your app can be rendered: // 一旦何も選択しないでOK
? Contentful CMA endpoint URL: api.contentful.com
? Would you like to specify App Parameter schemas? (see https://ctfl.io/app-parameters) No

完了すると、CMSダッシュボードでもAppが確認できるようになります。

https://app.contentful.com/account/organizations/{ORG_ID}/apps/

Screenshot 2024-10-16 at 11.49.29

いったんビルドしてデプロイしてみましょう。

% npm run build
% npm run upload

? Add a comment to the created bundle: initial commit
? Do you want to activate the bundle after upload? Yes
? Contentful CMA endpoint URL: api.contentful.com
? Please paste your access token: [hidden]

デプロイすると、CMSダッシュボードのAppが「Hosted by Contenful」に変わっており、ビルドされたコードがContentfulでホストされていることがわかります。

Screenshot 2024-10-16 at 11.50.56

本来、フロントエンドアプリであれば適用先Location(CMSのどの部分にAppを適用するか)を設定するのですが、今回はバックエンドアプリのため何も指定していません↓ ※逆に、詳細は後述しますが、デフォルトだとSpace全体に適用されるため、本番Spaceにはいきなり実装しないでください。

Screenshot 2024-10-16 at 11.53.48

コードを書いてみる

functions/example.ts

import { FunctionEventHandler as EventHandler } from '@contentful/node-apps-toolkit';
import {
  AppEventEntry,
  AppEventRequest,
  FunctionEventContext,
} from '@contentful/node-apps-toolkit/lib/requests/typings';
import type { EntryProps, PlainClientAPI, Link } from 'contentful-management';

// エントリーを取得: エントリー更新にバージョン情報が必要なため
const getEntry = async (
  cma: PlainClientAPI,
  entryId: string
) => {
  return await cma.entry.get({ entryId });
}

// エントリーのタグを更新
const updateEntryTags = async (
  cma: PlainClientAPI,
  entry: EntryProps,
  tags: Link<'Tag'>[],
  currentVersion: number
): Promise<EntryProps> => {
  try {
    const res = await cma.entry.patch(
      { entryId: entry.sys.id },
      [
        {
          op: 'replace',
          path: '/metadata/tags',
          value: tags,
        },
      ],
      {
        'X-Contentful-Version': currentVersion,
      }
    );
    return res;
  } catch (error) {
    console.error('Error:', error);
    throw error;
  }
}

// タグのリンクオブジェクトを作成
const createTagLink = (id: string): Link<"Tag"> => {
  return {
    sys: {
      type: "Link",
      linkType: "Tag",
      id
    }
  };
}

export const handler: EventHandler<'appevent.handler'> = async (
  event: AppEventRequest,
  context: FunctionEventContext
) => {
  try {
    const { cma } = context as FunctionEventContext & {
      cma: PlainClientAPI;
    };
    const { body } = event as AppEventEntry;
    const entryData = await getEntry(cma, body.sys.id);
    const currentVersion = entryData.sys.version;

    // 追加するタグ
    const newTags: Link<"Tag">[] = [
      createTagLink("tag1")
    ];

    const res = await updateEntryTags(cma, body, newTags, currentVersion);
    console.log('res:', JSON.stringify(res));

    // AppEvent Handlers don't have a response
    return;
  } catch (error) {
    console.error('Error handling event:', error);
  }
};

エントリーの公開イベントを受け取り、記事の詳細を取得し、その記事にタグを追加するというシンプルな流れです。

前提として、記事更新には 記事のバージョンの指定が必要 になります。

イベントペイロードの中身を確認すると、イベント種別によって version を持っていたり revisoin 情報だけが格納されていたり、構造にまだ一貫性がないようです。ContentfulのPMにフィードバックしたところ、近いスプリント中に整理するとのことでした。

上記例ではそのため、エントリーをバックエンド側から一度解決してその中からバージョン情報を取り出して更新処理に利用しています。

アプリの設定とインストール

イベントによるFunctionの起動を有効化します。

Screenshot 2024-10-16 at 12.36.51

Functionとして起動するハンドラーを指定し、トリガーしたいイベント(下記例では「Publish」)にチェックを入れます。

Screenshot 2024-10-16 at 12.37.36

その後、右上 [Save] を実行。

その後、デプロイされたアプリをSpaceにインストールします。

Screenshot 2024-10-16 at 12.35.00
Screenshot 2024-10-16 at 12.35.44

挙動の確認

インストールしたスペースで、適当に記事を作成してPublishしてみます。

Screenshot 2024-10-16 at 12.56.42

Tagsのタブに移動すると、無事タグが追加されていました。

Screenshot 2024-10-16 at 12.58.17

※注意点として ユーザー自身にタグ書き換え権限が必要 です。タグを含むメタデータ更新のためにcmaクライアントを利用しており、contextとして各ユーザーの権限が渡されているためです。

デバッグ

コンソールで実行ログを確認できます。

Screenshot 2024-10-16 at 12.39.11

console.logの内容が出力されています。

Screenshot 2024-10-16 at 13.02.17

Functionタイプについて

上記の例では インストールされたSpaceのすべてのEntryのPublishが対象 となっており、このままでは不便です。

そのため、前段でイベント自体をフィルターする必要が出てきます。

上記ハンドラーとは別途、下記のFunctionもAppの一部として登録することができます。

  1. appevent-filter ... 他の処理の前に実行され、イベントをトリガーするか破棄するかを決定する

  2. appevent-transformation ... リクエスト検証の前に実行され、リクエストのheaderとbodyの拡充を行う

Screenshot 2024-10-16 at 13.13.01

appevent.handler と同様に、コードを記述し、contentful-app-manifest.json で accept することで有効化できます。

appevent-filter では、例えば特定のモデルや条件に絞ってイベントをトリガーしたい時など、appevent.handlerの前段として実行したいフィルター処理を記述できます。

Event Topicについて

Functionをどのイベントでトリガーするかを指定できます。

Screenshot 2024-10-17 at 11.14.03

混乱しやすいのが「Save」と「Autosave」イベントの違いです。

  • Save ... ユーザー/バックエンド処理問わず、エントリーが変更された際に起動
  • Autosave ... ユーザー自身がエディタでエントリーを編集した際のみ に起動

つまり、ユーザーによる記事変更を起点とした処理を組み込みたい殆どのケースで、Autosave が最適になるはずです。

例えば「Save」をトリガーにして、記事の自動タグ付けを行うFunctionを設定した場合、記事更新が無限に行われるループが発生してしまいます。

その他のコード例

公式から色々なモックが公開されているため、手始めにこれらをいじってみるのもいいと思います。

最後に

記事の拡充、バリデーション、ログ、通知、外部連携など色々な使い道がありそうです。

もう少し触ってみてまた続報を書こうと思います。

参考資料

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.