Contentfulの新機能Functionsでバックエンド処理をカスタマイズする
ベルリンオフィスの小西です。
ヘッドレスCMSのContentfulで現在Betaとして利用可能な Functions 機能を触ってみました。
Contentfulカスタマイズ勢としては待ち望んでいた機能ですので、本記事ではその概要とハンズオンを紹介します。
Contentful Functionsとは
Contentfulのインフラストラクチャ上で動作するサーバーレスワークロードです。独自のサーバーを用意することなく、CMSダッシュボードをカスタマイズして機能を拡張することができます。
例えば下記のようなことが可能になります。
- Contentfulで発生するイベント(記事の保存/公開/非公開/...)をフィルタリング、変換、処理する。
- Contentful内のデータと外部のデータベース/APIを統合し、GraphQL APIを通じて取得できるようにする
- フロントエンドやサードパーティアプリによってトリガーされるアクションを処理するバックエンドコンポーネントをCMSに追加する
これまでも、外部でワークロードやアプリケーションをホストすることで上記一部はカバーできていましたが、今後はContentful単体で実現できる ようになり、障害点も減らすことができます。
なおいくつか制限があり、重すぎる処理は任せられない点に注意してください。
- Functionの最大実行時間は 5 秒
- 関数がタイムアウトした場合、再試行は行われない
- 機能はプライベートアプリでのみ利用可能
- 1アプリごとに Functions は 50 個まで
ハンズオン
では実際にFunctionを作成・デプロイしてみます。
今回は試しに「記事が公開されたら自動でタグを付与するバックエンドアプリ」を作成します。
CMS側のタグの作成
先にCMS側で、タグを作成しておきます。
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/
いったんビルドしてデプロイしてみましょう。
% 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でホストされていることがわかります。
本来、フロントエンドアプリであれば適用先Location(CMSのどの部分にAppを適用するか)を設定するのですが、今回はバックエンドアプリのため何も指定していません↓ ※逆に、詳細は後述しますが、デフォルトだとSpace全体に適用されるため、本番Spaceにはいきなり実装しないでください。
コードを書いてみる
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の起動を有効化します。
Functionとして起動するハンドラーを指定し、トリガーしたいイベント(下記例では「Publish」)にチェックを入れます。
その後、右上 [Save] を実行。
その後、デプロイされたアプリをSpaceにインストールします。
挙動の確認
インストールしたスペースで、適当に記事を作成してPublishしてみます。
Tagsのタブに移動すると、無事タグが追加されていました。
※注意点として ユーザー自身にタグ書き換え権限が必要 です。タグを含むメタデータ更新のためにcmaクライアントを利用しており、contextとして各ユーザーの権限が渡されているためです。
デバッグ
コンソールで実行ログを確認できます。
console.logの内容が出力されています。
Functionタイプについて
上記の例では インストールされたSpaceのすべてのEntryのPublishが対象 となっており、このままでは不便です。
そのため、前段でイベント自体をフィルターする必要が出てきます。
上記ハンドラーとは別途、下記のFunctionもAppの一部として登録することができます。
-
appevent-filter
... 他の処理の前に実行され、イベントをトリガーするか破棄するかを決定する -
appevent-transformation
... リクエスト検証の前に実行され、リクエストのheaderとbodyの拡充を行う
appevent.handler
と同様に、コードを記述し、contentful-app-manifest.json
で accept することで有効化できます。
appevent-filter
では、例えば特定のモデルや条件に絞ってイベントをトリガーしたい時など、appevent.handler
の前段として実行したいフィルター処理を記述できます。
Event Topicについて
Functionをどのイベントでトリガーするかを指定できます。
混乱しやすいのが「Save」と「Autosave」イベントの違いです。
Save
... ユーザー/バックエンド処理問わず、エントリーが変更された際に起動Autosave
... ユーザー自身がエディタでエントリーを編集した際のみ に起動
つまり、ユーザーによる記事変更を起点とした処理を組み込みたい殆どのケースで、Autosave
が最適になるはずです。
例えば「Save」をトリガーにして、記事の自動タグ付けを行うFunctionを設定した場合、記事更新が無限に行われるループが発生してしまいます。
その他のコード例
公式から色々なモックが公開されているため、手始めにこれらをいじってみるのもいいと思います。
- Comment Bot: function-comment-bot
- 自動タグ付け: autotagger
最後に
記事の拡充、バリデーション、ログ、通知、外部連携など色々な使い道がありそうです。
もう少し触ってみてまた続報を書こうと思います。