あなたのアプリに iCloud Calendar API を統合する方法

公開日

以前の記事では、Google Calendar APIをアプリに統合する方法を解説し、統合を動かすために必要な手順をひとつずつ説明しました。

iCloud Calendarの統合は、Google Calendarほど簡単ではありません。ドキュメントが少なく、AppleはiCloud Calendarをアプリケーションに接続するために開発者が何をすべきかを十分に説明していないからです。

本記事では、iCloud Calendar APIをアプリケーションに統合する方法を詳しく解説します。認証、サポートされる操作、制限事項、コードスニペット、そして統合を容易にするツールを取り上げます。

Apple iCloud Calendarはどのようなプロトコルと標準を使用していますか?

Apple iCloud Calendarは、カレンダー通信にCalDAV標準を使用しています。CalDAVはWebDAVを拡張したもので、クライアントがサーバー上でカレンダーやイベントを管理できるようにします。

イベントはICS(iCalendar)形式で表現されます。これはカレンダーデータのためのテキストベースのフォーマットです。つまり、あなたのアプリケーションはCalDAVリクエストとICSデータを使用してHTTP経由でiCloudカレンダーと通信できます。

利点として、これはAppleデバイスやオペレーティングシステム固有の実装ではないため、あらゆるプラットフォーム上のサーバーから利用できます。

残念ながらiCloudはカレンダー用のREST APIを提供していないため、iCloud Calendarをアプリに統合する唯一の方法はCalDAVです。

iCloudではどのように認証しますか?

通常、プラットフォーム(例えばGoogle Calendar)をアプリケーションに統合したい場合、そのプラットフォームで開発者アカウントを作成し、アプリケーションを登録し、スコープを設定し、テストユーザーを追加し、アプリ情報を入力して承認を申請します。

これらすべての手順を経て承認されると、エンドユーザーをプラットフォームのOAuth画面にリダイレクトできます。ユーザーはプラットフォームにログインし、アプリケーションが要求するスコープへのアクセスを明示的に許可します。エンドユーザーは、あなたがそのプラットフォームで設定したアプリ名や情報も確認できます。

Apple iCloudはこのようには機能しません。iCloudにはGoogle CalendarやOutlookのような標準的なOAuthフローがありません。ユーザーのiCloud Calendarを接続するには、SSL上のBasic認証を使ってApple IDで認証します。多くのiCloudアカウントでは二要素認証が有効になっているため、ユーザーはApple IDのアカウント設定でアプリ固有のパスワードを作成し、メインのAppleアカウントのパスワードの代わりにそれをあなたのアプリで使う必要があります。

アプリはユーザーにiCloudのメールアドレス/Apple IDと、この16文字のアプリ固有パスワードの入力を求めます。これらの資格情報を使ってiCloudのCalDAVサービスに接続できます。

上記の理由に加え、Appleはセキュリティ強化のためにアプリ固有パスワードを要求しています。Apple iCloudのパスワードをサードパーティアプリと共有するのは最善策ではありません。

内部的には、HTTPリクエストのヘッダーにBase64エンコードされたアプリ固有パスワードが次の形式で含まれます: Authorization: Basic <app-specific-password-here>

iCloud Calendar APIでサポートされるメソッドは?

iCloudのCalDAVサービスはcaldav.icloud.comにホストされています。認証後、以下のメソッドが利用可能です。

  • ユーザーのカレンダーの一覧取得
  • イベントのCRUD
  • 特定イベントの取得

CalDAV標準の詳細については、利用可能なメソッドやフィルターなどを説明しているRFC 4791を参照してください。

以下では、iCloud Calendar統合に必要な最重要ポイントをまとめます。

サポートされるHTTP動詞

HTTP動詞CalDAVでの役割iCloudのサポート注意点
OPTIONSサーバー機能の検出デバッグに便利。本番では必須ではない。
PROPFINDプリンシパル、calendar-home-setの取得、カレンダー一覧、プロパティ取得事前に認証が必要。Depthは0または1を使用。
MKCALENDAR新しいカレンダーコレクションの作成書き込み権限が必要。Section 5.3.1を参照。
REPORTデータの検索(calendar-querycalendar-multigetfree-busy-query3つのREPORTは仕様で必須。iCloudでも利用可能。
PUT1件の.icsリソース(イベント/タスク)のアップロードまたは置換完全なVCALENDARを送信する必要あり。PATCH非対応。
DELETEイベントまたはカレンダーの削除安全のためIf-MatchのETagと併用。
COPY / MOVEカレンダー間でイベントをコピー/移動PUTと同じ前提条件が適用。
GET1件の.icsリソースを取得text/calendarとETagを返す。

カレンダーコレクションのプロパティ

プロパティ目的iCloud特有の注意点
CALDAV:calendar-description人が読める説明完全対応
CALDAV:calendar-timezoneクエリのデフォルトタイムゾーン対応
CALDAV:supported-calendar-component-set受け付けるコンポーネント(VEVENT, VTODO)イベントとタスクは別々のカレンダー
CALDAV:supported-calendar-data許可されるMIME/バージョン(通常text/calendar 2.0)iCloudのデフォルト
CALDAV:max-resource-sizeイベントあたりの最大サイズiCloudは約20 MB制限
CALDAV:min/max-date-time, max-instances, max-attendees-per-instanceさまざまなサーバー制限403/507エラーを避けるため遵守

統合を簡単にするために使用できるライブラリは?

CalDAV、ICS、XML、そしてiCloud Calendar特有の注意点を自分で扱うのは理想的とは言えません。各メソッドを理解し、XMLをJSONに変換し、コードに組み込むために多くの時間を費やすことになります。

より快適に統合を行うために、次のライブラリを推奨します。

  • tsdav : JavaScript/TypeScriptを使用する場合の必需品です。tsdavを使うと、CalDAV特有の構文や用語を使用せずにiCloudサーバーと簡単に通信できます。すべてのHTTP動詞とXMLを高水準のTypeScript APIでラップしています(PROPFIND、REPORT、MKCALENDAR、PUT、DELETEなど)。詳細は tsdavドキュメント をご覧ください。
  • ical-generator : CalDAVでiCloud Calendarを統合する場合、イベントを作成または更新する際に_完全な .icsファイルをアップロードまたは置換_する必要があります。これらのファイルを手書きするのはエラーの元で、すべてのVEVENTに正しいヘッダー、UID、DTSTART/DTEND、RRULE、タイムゾーンなどが必要です。ical-generatorはこれらの問題を解決します。
  • ical.js : Mozilla Calendarチームが作成した純粋なJavaScriptのパーサ/エンジンで、icsレスポンスをJSクラスに変換します。

以下の表は、iCloud Calendar統合におけるtsdavの役割を説明します。

スタックでの役割tsdavが行うことiCloudで重要な理由
CalDAV / WebDAVクライアントすべてのHTTP動詞とXMLをラップした高水準TypeScript APIを提供生のXML生成やmultistatusレスポンス解析ではなくビジネスロジックに集中できる
ディスカバリヘルパーcreateDAVClient()がCalDAVのディスカバリフローを自動実行し、calendar-home-setを解決してpXX-caldav.icloud.comのベースURLを保存iCloud固有の2ステップディスカバリを自動化し、ボイラープレートを排除
認証ラッパーBasicOAuth 2のビルトインヘルパー。iCloudでは{ username: 'user@icloud.com', password: '<app-specific-pw>', authMethod: 'Basic' }を渡すだけ資格情報のBase64エンコードやヘッダー注入が不要
一般タスク向け型付きヘルパーfetchCalendars(), fetchCalendarObjects(), createCalendarObject(), updateCalendarObject(), deleteCalendarObject()がXMLではなくプレーンJSオブジェクトを返却/受理RFC 4791のXML構文を気にせず高速にCRUDを実装
SyncトークンサポートsyncCollection()がCalDAVのsync-collection REPORTをラップし、トークンを追跡して変更/削除アイテムのみ返すプッシュのないiCloudでポーリング実装を1行で実現
Browser + Node互換fetchのアイソモーフィック実装でサーバー(Node)でもブラウザでも動作ブラウザ拡張機能やSPAの一部として動作させる場合に便利
型付きモダンTSプロジェクト完全な型定義、ツリーシェイカブルなESモジュール、最小依存モダンなビルドパイプラインに簡単に統合

tsdav + TypeScriptを使ったiCloud Calendar統合例

ここでは、すでにユーザーのユーザー名とAppleのアプリ固有パスワードを取得していると仮定します。

createClient: DAVClientを作成するメソッド:

import { DAVCalendar, DAVClient, DAVNamespaceShort, DAVObject } from "tsdav";

const APPLE_DAV_URL = "https://caldav.icloud.com";

function createClient({
  username,
  password,
}: {
  username: string;
  password: string;
}) {
  const client = new DAVClient({
    serverUrl: APPLE_DAV_URL,
    credentials: {
      username,
      password,
    },
    authMethod: "Basic",
    defaultAccountType: "caldav",
  });

  return client;
}

constants: 定数を含むファイル

/*
The prodId value is a required field that must appear on every calendar object.
The field is a globally-unique identifier for the software that 
produced the file. tsdav might automatically generate that information for you,
but it might be best if you provide it manually.

You might find it useful when you have edge cases, you can use it to tell which
program wrote the data.

*/

export const PROD_ID = {
  company: "your-company-name-here",
  product: "your-product-name-here",
};

getCalendars: すべてのカレンダーを取得するメソッド

  async getCalendars(
    ...params: Parameters<typeof DAVClient.prototype.fetchCalendars>
  ): Promise<DAVCalendar[]> {
  
  // You can abstract this initialization into another method.
  // For the sake of simplicity, we'll initialize the client on each method.
   const client = createClient({
    username: <email-here>,
    password: <password-here>,
  });
  
    return client.fetchCalendars(...params);
  }

getCalendarById: IDでカレンダーを取得するメソッド

  async getCalendarById(calendarUrl: string): Promise<DAVCalendar> {
    const calendars = await getCalendars();
    const calendar = calendars.find((el) => el.url === calendarUrl);
    if (!calendar) {
      throw new Error(`Apple Calendar with id ${calendarUrl} not found`);
    }

    return calendar;
  }

getCalendarEvents: カレンダーイベントを一覧取得するメソッド

  async getCalendarEvents(
    calendarUrl: string,
    query: GetCalendarEventsQuery = {}
  ) {
    const client = createClient({
      username: <email-here>,
      password: <password-here>,
    });

    const calendar = await getCalendarById(calendarUrl);

    const events = await client.fetchCalendarObjects({
        calendar,
        timeRange: query.dateRange ?? undefined,
      })

    return { events, nextSyncToken: calendar.syncToken };
  }

getEventById: IDでイベントを取得するメソッド

  async getEventById(calendarUrl: string, eventId: string) {
    const client = createClient({
      username: <email-here>,
      password: <password-here>,
    });
    
    const eventUrl = new URL(`${eventId}.ics`, calendarUrl).pathname;

    const responses = await client.calendarMultiGet({
        url: calendarUrl,
        props: {
          [`${DAVNamespaceShort.DAV}:getetag`]: {},
          [`${DAVNamespaceShort.CALDAV}:calendar-data`]: {},
        },
        objectUrls: [eventUrl],
        depth: "1",
      })
    

    if (responses.length === 0) {
      throw new Error(
        `Received no response while fetching ${eventUrl}`,
        null
      );
    } else if (responses[0].status >= 400) {
      throw new Error(
        `Failed to get Apple event by id. Status: ${responses[0].statusText}`,
        responses[0]
      );
    }

    const response = responses[0];
    const calendarObject: DAVObject = {
      url: new URL(response.href ?? "", calendarUrl).href,
      etag: `${response.props?.getetag}`,
      data:
        response.props?.calendarData?._cdata ?? response.props?.calendarData,
    };

    try {
      return calendarObject;
    } catch (err: any) {
      this.logger.error("Failed to process Apple Response", {
        message: err.message,
        event: calendarObject,
      });
      return [];
    }
  }

createEvent: イベントを作成するメソッド

  async createEvent(calendarUrl: string, data: AppleEvent) {
    const client = createClient({
      username: <email-here>,
      password: <password-here>,
    });

    const eventId = data.id ?? <generate-id-here>

    const calendar = ical({
	    prodId: PROD_ID,
      method: ICalCalendarMethod.REQUEST,
    });
    
    const event = calendar.createEvent({ ...data, id: eventId });

    const response = await  client.createCalendarObject({
        calendar: {
          url: calendarUrl,
        },
        filename: `${event.id()}.ics`,
        iCalString: calendar.toString(),
      })
      
    if (!response.ok) {
      throw new Error(
        `Failed to create Apple event: ${response.statusText}`,
        response
      );
    }
    
    return { id: event.id(), eventWithExceptions };
  }

updateEvent: イベントを更新するメソッド

  async updateEvent(calendarUrl: string, eventId: string, data: AppleEvent) {
    const client = createClient({
      username: <email-here>,
      password: <password-here>,
    });

		
    const originalEventData = await getEventById(calendarUrl, eventId);

    const calendar = ical({
      prodId: PROD_ID,
      method: ICalCalendarMethod.REQUEST,
    });

    for (let event of originalEventData) {
      if (event.status === ICalEventStatus.CANCELLED) continue;

      if (event.id === eventId) {
        calendar.createEvent({ ...event, ...data, id: eventId, url: null });
      } else {
        calendar.createEvent({ ...event, id: eventId, url: null });
      }
    }

    const calendarObjectUrl = new URL(`${eventId}.ics`, calendarUrl);

    const response = await
      client.updateCalendarObject({
        calendarObject: {
          url: calendarObjectUrl.href,
          data: calendar.toString(),
        },
      })

    if (!response.ok) {
      throw new Error(
        `Failed to update Apple event: ${response.statusText}`,
        response
      );
    }

    return getEventById(calendarUrl, eventId);
  }

deleteEvent: IDでイベントを削除するメソッド

  async deleteEvent(calendarUrl: string, eventId: string) {
    const client = createClient({
      username: <email-here>,
      password: <password-here>,
    });
    

    const calendarObjectUrl = new URL(`${eventId}.ics`, calendarUrl);

    const response = await client.deleteCalendarObject({
        calendarObject: {
          url: calendarObjectUrl.href,
        },
      })

    if (!response.ok) {
      throw new Error(
        `Failed to delete Apple event: ${response.statusText}`,
        response
      );
    }

    return { id: eventId };
  }

Apple iCloud Calendarの制限事項

  1. REST APIやJSONレスポンスがない: iCloud Calendar APIと通信するには、CalDAVを使い、.icsファイルを扱う必要があります。 Outlook Calendar API やGoogle Calendar APIはJSONを扱うREST APIを備えており、はるかに生産的に開発できるため、これは理想的とは言えません。先ほど挙げたライブラリを使えばiCloud Calendar APIとの通信は楽になりますが、最終的には使用する言語やフレームワーク次第です。すべてのフレームワークにiCloud Calendar APIとの通信を容易にするライブラリがあるわけではありません。
  2. ドキュメントの不足 : 公式のCalDAVドキュメントへのリンクを示しましたが、iCloud Calendar APIと通信する際にはすべてのメソッドが動作するわけではないことに注意してください。落とし穴と多くのテストに備えておきましょう。
  3. アプリ固有パスワードによるBasic認証 : 前述のとおり、iCloudは標準的なOAuth 2.0の規約に従っていないため、認証と通信にはアプリ固有パスワードを使用する必要があります。
  4. Webhook/プッシュ通知の非対応 : Google CalendarやOutlookとは異なり、iCloudには 最高のカレンダーAPI と言えるような機能がなく、カレンダーの変更を通知するWebhookを登録できません。サードパーティアプリはリアルタイム更新を購読できないため、定期的にポーリングし、 sync-collection REPORTを使って効率的に変更を取得する必要があります。
  5. PATCHメソッド非対応 : イベントを部分更新するPATCHメソッドを使用できず、完全なPUTでイベントを更新する必要があります。
  6. 招待の制御不可 : iCloud Calendarは会議招待を自動処理します。出席者付きのイベントを作成または変更すると、iCloud Calendarが招待状を送信し、出席者のステータスを更新します。CalDAVのscheduling Outbox/Inboxを使用して招待を手動で制御することはできません。

iCloud Calendarをアプリケーションに統合する簡単な方法はありますか?

iCloud Calendarをアプリに統合するのは簡単な作業ではありません。iCloudは標準的なカレンダー規約に従っておらず、本来動くはずの多くのCalDAVメソッドやフィルタが動作しないためです。

より簡単な方法は、Unified Calendar APIを利用することです。

Apiroc Unified Calendar API Illustration

ApirocのようなUnified Calendar APIを利用すると、次のメリットがあります。

  • 最新の標準に従った、十分にドキュメント化されテストされたAPIでiCloud Calendarを統合できる。 Apirocでは、Apple IDのメールアドレスとアプリ固有パスワードを使って1回のAPI呼び出しでiCloudアカウントを接続でき、イベントはICSではなくJSONで返されます。
  • 開発と保守にかかる時間を削減できる。 API、クライアント、エッジケースなど、作業の大部分はすでに解決済みです。統合の保守や、後から出てくる新しいエッジケースの修正も必要ありません。
  • iCloud Calendar以外のカレンダープロバイダーも追加作業なく統合できる。 ApirocはGoogle CalendarとMicrosoft Outlookにも同じAPIで対応しているため、アプリに他のプロバイダーを追加するのも簡単です。
  • Google CalendarとOutlookのWebhookに対応。iCloudのWebhookは対応予定。 Apirocは現在、GoogleとMicrosoftのカレンダーに対してリアルタイム通知を送信します。iCloudカレンダーのWebhookは対応を予定しています。それまでの間、iCloudの変更はポーリングで取得する必要がありますが、Apirocは同期トークンでこれを簡単にします。 Apiroc Unified Calendar API - Support for webhooks

Apirocを使ってiCloud Calendarをアプリに統合しよう

iCloud Calendarをアプリケーションに統合する際のさまざまな落とし穴に、自分で悩む必要はありません。代わりに、長年カレンダー統合に携わってきたチームが作ったUnified Calendar API、Apirocをご利用ください。無料プランには最大10個のEnd User Accountと無制限のAPIリクエストが含まれます。クレジットカード不要で無料登録できます。

よくある質問

iCloud カレンダーはどのプロトコルを使用していますか?

CalDAV を HTTP 経由で使用し、イベントを iCalendar(ICS)形式で保存します。

iCloud カレンダーに REST API はありますか?

いいえ。CalDAV が iCloud カレンダーのデータを読み書きする唯一の方法です。

iCloud カレンダーへの認証方法は?

ユーザーの Apple ID(通常はメールアドレス)と 16 桁のアプリ専用パスワードを Basic 認証で SSL 経由で送信します。

iCloud カレンダーはプッシュ通知や Webhook をサポートしていますか?

いいえ。変更を検知するにはサーバーをポーリングする必要があります。

統合を簡単にするライブラリは?

tsdav(CalDAV クライアント)、ical-generator(ICS 生成)、ical.js(ICS 解析)が主要な低レベル処理を担います。

CalDAV や ICS を直接扱わずに済ませる方法は?

iCloud、Google、Outlook を 1 つのモダンな JSON インターフェースでまとめた Apiroc などの Unified Calendar API を利用してください。