Google Calendar APIの基本とはまったポイント

## はじめに

この記事は株式会社ビットキー Advent Calendar 2022 22日目の記事です。
Workspace & Experience Product Circle所属の古川（@kzfrkw）が担当します。

おそらくみなさん一度はGoogleカレンダーを利用したことがあるのではないでしょうか？
個人だったり会社だったりあると思いますが、私も会社で社内システムとしてGoogle Workspaceを利用しているため、日常的にGoogleカレンダーを利用しています。

またビットキーが提供するworkhubというシステムにおいても、Googleカレンダーと連携することでイベントの同期をする機能を提供しています。

今回はGoogle Calendar APIを用いて、Googleカレンダー側に何か更新があった場合にそれを検知してデータを取得し処理をするケースにおける、基本的な情報やはまりポイントを紹介できればと思います。
Google Calendar APIを使って何か作ってみたい、会社でやることになったけど気をつけるべきポイントを知っておきたいといった方々にとって有用な記事になれば幸いです。

なお、サンプルのソースコードは全てTypeScriptで記載しています。

## 基本のき
### そもそも**Google Calendar API**とは
[公式ドキュメント](https://developers.google.com/calendar/api/guides/overview)によれば、Google Calendar APIは「明示的な HTTP 呼び出しまたは Google クライアント ライブラリを介してアクセス可能な RESTful API」です。公開されているAPIを実行することで、HTTP呼び出しなどにより簡単にGoogleカレンダー上のイベントを取得したり作成したりすることができます。
(API実行時には当然認証などをクリアする必要がありますが、この記事ではその辺りには触れません。認証には一般的なOAuthのフローなどが利用可能です。）

### Googleカレンダー上の変更を検知するには
多様なAPIが公開されているので、どんなものがあるかは公式ドキュメントを参照いただきたいですが、特に今回例に挙げる「Googleカレンダー側に何か更新があった場合にそれを検知してデータを取得する」とうい動きを実現するには、Googleカレンダー側の変更検知の手段としてプッシュ通知を利用します。（公式ドキュメントは[こちら](https://developers.google.com/calendar/api/guides/push)）

簡単に言えば、事前にリクエストを受け付けることが可能なHTTPSサーバーを用意してそのURLをGoogle Calendar APIで登録しておくことにより、Googleカレンダー上で何か変更があった場合に登録したURLに対して通知を投げてくれるようになります。

ただし、このプッシュ通知は、対象のリソース（Google Workspace上での会議室や備品などのマスタデータ）に対して**何かしらの変更があった**ということしか教えてくれないので、プッシュ通知を受け取った際にこちらから情報を取得しにいく必要があります。

プッシュ通知のイベントには`sync`と`exists`の２種類あり、前者は新しくプッシュ通知を登録したときに最初に送られるイベント、後者はその後に何かリソースに対して更新があったことを通知する際のイベントです。

上記を考慮すると、プッシュ通知を受け取ったときの処理をするclassはざっくり以下のようになると思います。

```tsx
export class GoogleCalendarSyncService {
  private readonly webHookTriggerState?: GoogleCalendarWebHookTriggerState;

  constructor(data: Param) {
    this.webHookTriggerState = data.webHookTriggerState;
  }
  public static init = (data: Param) =>
    new GoogleCalendarSyncService(data);

  public handle = async (): Promise<void> => {
    if (this.webHookTriggerState === 'sync') {
      // プッシュ通知登録後の初回の連携処理
      await this.handleWithSync();
    } else if (this.webHookTriggerState === 'exists') {
      // 通常時の連携処理
      await this.handleWithExists();
    }
  };
```

`sync`と`exists`のそれぞれのケースにおける処理はざっくり以下のような感じです

### sync

```tsx
import {google} from 'googleapis'; // googleapisを使って実装

handleWithSync = async () => {
	const googleCalendarApi = google.calendar({version: 'v3', auth}); // authはOAuthなどで取得したもの
	const resourceId = "hoge"; //  Google calendar上のリソースのid
	const eventListRes = await googleCalendarApi.events.list({
	  calendarId: resourceId,
	  showDeleted: true // 削除済みのイベント情報も含めて取得する
	);
	let nextSyncToken = eventListRes?.data.nextSyncToken;
	let nextPageToken = eventListRes?.data.nextPageToken;
	const allEventItems = [...eventListRes.data.items];
	while (nextPageToken) {
	  const eventListRes = await googleCalendarApi.events.list({
	    calendarId: resourceId,
	    nextPageToken, // nextPageTokenがある場合はevents.listがページングされている
	    showDeleted: true
	  });
      allEventItems.push(...eventListRes.data.items);

	  if (eventListRes?.data.nextSyncToken && !eventListRes?.data.nextPageToken) {
		// nextPageTokenを受け取らなかった場合はwhileを抜ける
	    nextSyncToken = eventListRes.data.nextSyncToken;
	    nextPageToken = undefined;
	    break;
	  } else {
	    if (!eventListRes?.data.nextPageToken) {
	      this.logger.error(`No nextSyncToken and no nextPageToken!!`);
	      throw new Error('no-next-token');
	    }
		// nextPageTokenを受け取った場合はまだページング続いているので詰め直してwhile回す
	    nextPageToken = eventListRes.data.nextPageToken;
	  }
	}
	// ここまででallEventItemsには既存のイベントが含まれているのでそれをDBに保存したり処理をする
	// また、最後に取得したnextSyncTokenをDBに保存しておく
}
```

### exist

```tsx
handleWithExists = async () => {
  const nextSyncToken = await loadToken(); // DBに保存したnextSyncTokenをロード
	const resourceId = "hoge"; //  Google calendar上のリソースのid
	const googleCalendarEventList = await googleCalendarApi.events.list({
	  calendarId: resourceId,
      nextSyncToken, // ここでnextSyncTokenを渡すことで、前回からの差分のイベント(新規・更新含む)を取得することができる
	  showDeleted: true // 削除済みのイベント情報も含めて取得する
	);
	const newNextSyncToken = googleCalendarEventList?.data.nextSyncToken;
	// 再度取得したnewNextSyncTokenをDBに保存し、次回のexistsの処理に備える
	const events = googleCalendarEventList.data.items;
	// 取得したeventsに対して、DBに保存するなどよしなに処理をしていく
}
```

## はまりポイント① nextSyncTokenについて

### イベントの件数が多いと一度でnextSyncTokenが取得できない

上記のsyncの実装例でやっているように、nextPageTokenが返される間は繰り返し処理をしないとイベントの全量を取得できません。

まぁこれは公式ドキュメントの実装例にも書いてあることではあるんですが、Googleカレンダー上のリソースを新しく作ってそれを検証に利用していると、イベントが少なくて一回で全量取れてしまうので、意外と見落としてしまうかなと思います。私もこれを実際踏んだため、ちゃんとドキュメント読んで実装し直しました。

### たまに意図せずtokenが無効になっていることがある

プッシュ通知には当然有効期限があるため、更新をしないと一定期間でプッシュ通知されなくなってしまいます。そのため、恒常的に通知を受け取れるようにするためには定期的に更新処理をする必要があります。

デフォルトの有効期限については公式ドキュメントで明記されている部分を見つけられなかったのですが、実際開発している中では大体1週間くらいで通知が来なくなりました。そのため、日次などで更新処理をするのがいいのではと思います。

プッシュ通知を更新する処理について、日次で通知の停止と再登録をする形で実装をしたのですが、しばらく運用をしているとたまに処理が失敗していることがありました。ログを見たところ以下のようなメッセージが出ていました。

```tsx
410 Sync token is no longer valid, a full sync is required.
```

連携のたびにnextSyncTokenを更新していれば本来この状態にはならないと思うのですが、ドキュメントにも記載がある通り様々な理由で意図せずtokenが無効になってしまうケースがあるようです。

[https://developers.google.com/calendar/api/guides/sync#full_sync_required_by_server](https://developers.google.com/calendar/api/guides/sync#full_sync_required_by_server)

そのため、上記のエラーが起きた場合はnextSyncTokenを破棄して0から連携をし直す必要があります。

```tsx
try {
	// handleWithSyncのような連携処理
	// 最初のawait　googleCalendarApi.events.listに保存済みのnextSyncTokenを渡す
} catch (e) {
  if (e.code === 410) {
    // 保存していたnextSyncTokenは無効なので破棄する
    // 最初のawait　googleCalendarApi.events.listでnextSyncTokenを指定せずに
    // handleWithSyncの連携処理をやり直す
  } else {
    this.logger.error('failed to handle sync', e);
    throw e;
  }
}
```

### 指定できないパラメータがある

これはドキュメントにも記載がなく実際やってみないとわからないやつだったのですが、events.listのAPIを実行するときに幾つか指定できないパラメータがありました。

具体的に自分が踏んだものとしては、showDeletedをfalseにしたり、timeMinを使うとBad Requestになるという感じでした。

```tsx
Sync token cannot be used with other request restrictions., syncTokenWithRequestRestrictions
```

この点については以下の記事が参考になりました。

[Google Calendar APIでnextSyncTokenが返らないときの対策 - Qiita](https://qiita.com/shiraily/items/6762046f041551c269da)

## はまりポイント② プッシュ通知の条件

### イベントの内容に更新がないと、Googleカレンダーのイベント更新してもプッシュ通知されない

当たり前ではありますが、変更を通知するという機能なのでイベントを空更新してもプッシュ通知はされないです。

実際の開発のユースケースで言えば、API経由でイベントを更新する場合に予約内容は実質変わらないけどプッシュ通知はしてほしいケースが出てきたときに、上記の仕様にハマりました。

更新時にプッシュ通知されない件への対処方法としては、`extendedProperties` というユーザー側で任意追加できるプロパティにタイムスタンプの値を入れることで対応しました。これにより、イベント自体の内容は実質変わらなくてもシステム的に変更があったと検知させ、プッシュ通知を発火させることができました。

## はまりポイント③ 繰り返し予約について

### 繰り返し予約の基本仕様

Googleカレンダーにおける繰り返しの予約のイベントは、RRule([RFC5545](https://www.rfc-editor.org/rfc/rfc5545))で表現されます。イベントデータ内に`recurrence`というフィールドがあり、そこにRRuleの形式で条件が記載されます。

例
毎日→"RRULE:FREQ=DAILY”
毎週火曜→"RRULE:FREQ=WEEKLY;BYDAY=TU”

そのため、基本的には繰り返しのイベントでもデータ的には一つだけです。ただし、Googleカレンダーの繰り返しの予定の更新方法として「この予定のみ」や「これ以降の予定」などを指定して編集することができ、その場合イベントデータが分裂していく形になります。

例えば、毎日の繰り返しのイベントに対して途中でこれ以降の予定の更新をかけるとイベントが二つになり、元々あったイベントの`recurrence`が以下のようになります。

"RRULE:FREQ=DAILY;UNTIL=20221222T145959Z"

そのため、繰り返しの予定に対してこの日だけ変更やこれ以降変更を繰り返していくと、イベントデータが増えていきます。

### 参加者の参加不参加だけでもイベント更新される

参加者がそのイベントに対して参加・不参加を登録するだけでも、予定の更新と見做されてプッシュ通知が実行されます。参加・不参加もその日だけ不参加などの登録ができるので、その登録の度にイベント情報が増殖していきます。

なので、参加者たくさんいる予定（会社だと数十人が参加者として登録されているイベントが結構ある。。）だと、参加不参加の登録によりイベントデータが分裂しやすくなります。
イベントが分裂すると、分裂したイベント同士の関連性を把握するのが困難になっていくため、取得したイベント情報に対して何かしらの処理をしていくのが非常に難しいです。

繰り返し予約について言えば細かい仕様などもっとあるんですが、入り切らないのでどこか別の場で書ければなと思っています。

## まとめ

わかってしまえば当たり前のことでも、実装する中では意外と気づかなかったり実際にやってみるまでわからないことってあるよなというのが記事を書いてみた感想です。（小並感）

この記事がどこかで同じようにはまっている人の一助になれば幸いです。

明日の23日目の株式会社ビットキー Advent Calendar 2022は、BKP&HubCoreModule所属の@BYAKheeeが担当します。