【Ruby on Rails】TimeTreeの予定をRailsから操作する方法

こんにちは、株式会社Pentagonでエンジニアをしているakitoshigaと申します。

突然ですが、みなさんTimeTreeというアプリをご存知でしょうか。
TimeTree(タイムツリー)とは、複数の人々が共有カレンダーを使用してスケジュールを調整し、予定を共有するためのカレンダーシェアアプリです。

今回このTimeTreeの予定の作成・更新・削除をRuby on Rails(以下Rails)から行う機会がありました。
Railsからこれらの操作をする情報についてはあまりネット上に載っていなかったので、今回の記事では実際にRails上でTimeTreeの操作をする方法や手順について紹介します。

【こんな人に読んで欲しい】

  • RailsからTimeTreeを操作したい人
  • その他OAuthを用いてユーザーのリソースを操作したい人

【この記事を読むメリット】

  • TimeTreeの予定をRailsから操作する方法がわかる!
  • 実装の過程でハマった時に確認するポイントがわかる!

【結論】

TimeTreeにはAPIが用意されており、その一種であるOAuth Appを利用することによって、予定の作成・更新・削除が可能です。
OAuth Appを利用するためには、事前にTimeTree App Consoleから専用のOAuth Appの作成が必要になります。

【注意】

残念ながら、TimeTreeのAPIは2023年12月22日をもって提供を終了してしまうそうです。
TimeTreeのAPIをサービスに組み込む事などを検討されている方は、十分にご注意ください。

目次

検証環境・前提条件

検証環境

  • Ruby バージョン3.2.2
  • Ruby on Rails バージョン7.0.6

前提条件

OAuth Appの作成

TimeTree App ConsoleのOAuth Appsに遷移してください。※ 要アカウント登録
「アプリの作成」ボタンを押下してください。

モーダルが表示されるので、「App Name」「Redirect URL」を入力してください。
「TimeTree開発者ガイドライン」に同意して、「作成」ボタンを押下してください。

「App Name」は任意の名前で構いません。
また「Redirect URL」は後述のOAuthの認可コードが送信されるURLです。
今回はhttp://localhost:3000/time_tree/callbackとしました。
実行する環境と疎通が取れれば任意の値でかまいませんが、慣例としてエンドポイントの末尾はcallbackとすることが多いようです。

「次へ」のリンクを押下してください。

「クライアントID」、「クライアントシークレット」が発行されます。
これらはRails内で利用するため控えておいてください。
控えたら、「次へ」のリンクを押下してください。

必要なスコープにチェックをしてください。
今回必要なスコープはカレンダーの「読み取り」と、予定の「読み取り」「書き込み」になります。
チェックが完了したら、「保存」ボタンを押下してから「次へ」のリンクを押下してください。


認証用のURLが表示されます。
こちらもRails内で利用するため、控えておいてください。

これでOAuth Appの作成は完了です!
次はサンプルコードを紹介します。

サンプルコード

今回はAPIのリクエストにnet-httpを利用しました。
Gemfileに下記のようにGemを配置したら、ターミナル上でbundle installを実行してください。

gem 'net-http'

TimeTreeの認可サーバーからユーザーのAPIトークンを取得する

ユーザーのTimeTreeの予定を操作するには、そのユーザーのTimeTreeアカウントと紐づいたAPIトークンが必要です。
APIトークンを取得するには、ユーザーに実際にブラウザを操作してもらって、Railsがユーザーに代わってリソースの操作を行うことを許可してもらう必要があります。
TimeTreeではこの一連の流れにOAuth2.0を利用しています。
OAuth2.0とはリソース認可のプロトコルです。
この取り決めに従い、TimeTreeの予定を操作するためのAPIトークンを取得する必要があります。

OAuth、OAuth2.0については、下記の方が詳しく解説しています。
一番分かりやすい OAuth の説明

サンプルコードではこのような形で実装しました。

# GET TimeTreeで登録したRedirect URL
def callback
  # 認可コードからアクセストークンを取得する
  code = params[:code]
  credentials = TimeTreeApiClient.credentials_from_code(code)

  # ユーザーIDと紐付けてAPIトークンを保存
  TimeTreeApiCredentialsManager.store_credentials(current_user.id, credentials) # TimeTreeApiCredentialsManagerはユーザーのAPIトークンを保存するための独自クラス
  redirect_to action: :index
end

# TimeTreeのAPIクライアント
class TimeTreeApiClient
  class << self

    def credentials_from_code(code)
      uri = URI.parse('https://timetreeapp.com/oauth/token') # APIトークンの取得先
      request = Net::HTTP::Post.new(uri)
      request['Content-Type'] = 'application/json'
      request.body = {
        code: code,
        client_id: ENV['TIMETREE_CLIENT_ID'], # TimeTreeから発行されたクライアントID
        client_secret: ENV['TIMETREE_CLIENT_SECRET'], # TimeTreeから発行されたクライアントシークレット
        grant_type: 'authorization_code',
        redirect_uri: 'http://localhost:3000/time_tree/callback'
  }.to_json

      response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

      JSON.parse(response.body).symbolize_keys[:access_token] # :acces_tokenにAPIトークンが格納されている
    end
  end
end

ビュー側にこのような形で導線を作成して、OAuth Appを作成すると取得できる認証用のURLにユーザーに遷移してもらいます。

<%= button_to '認証する', "#{OAuth App作成時に取得した認証用のURL}" %>

遷移してもらった先にあるTimeTreeの認証画面で、Railsがユーザーに代わって、TimeTreeの予定を操作することを許可してもらいます。
ユーザーが許可をすると、OAuth Appに登録した「Redirect URL」のhttp://localhost:3000/time_tree/callbackにリダイレクトします。
登録したURLにマッピングしたRailsのアクション#callback でパラメーターとして付与された認可コードを受け取ります。
受け取った認可コードをTimeTreeApiClient#credential_from_codeの引数に渡します。
引数から受け取った認可コードをリクエストボディに格納して、https://timetreeapp.com/oauth/tokenにPOSTリクエストを送信します。
そうすると、認可サーバーからのレスポンスボディにAPIトークンが格納されて返ってきます。
ここで取得したAPIトークンをRailsで保存すれば完了です。

APIトークンは基本的にユーザーごとに一度取得すればよいので、TimeTreeを操作するたびに毎回この手順を踏む必要はありません。
また、注意点としてAPIトークンを取得するためのPOSTリクエストには認可コードの他にもリクエストボディに下記を含む必要がありますのでご注意ください。

  1. client_id → TimeTreeから発行されたクライアントID
  2. client_secret → TimeTreeから発行されたクライアントシークレット
  3. grant_typeauthorization_code
  4. redirect_uri → OAuth App作成時に登録した、リダイレクトURI
    詳細はこちらに記載されています。

では取得したAPIトークンを使って、いよいよTimeTreeの予定の操作をしていきましょう!

基本仕様

TimeTreeに予定の操作に関するリクエストを送信する際は基本的に下記の仕様を満たす必要があります。

  • ベースURI
    https://timetreeapis.com

  • リクエストヘッダ

    1. Acceptapplication/vnd.timetree.v1+json
    2. AuthorizationBearer ${認可サーバーから受け取ったAPIトークン}
    3. Content-Typeapplication/json
      各APIの詳細な仕様についてはこちらに記載されているので、必要に応じて確認してみてください。

カレンダーIDの取得

TimeTreeでは予定の操作をするのに、まずユーザーのカレンダーIDを取得する必要があります。
カレンダーIDは下記のエンドポイントにGETリクエストを送信することで取得することができます。
/calendars

サンプルコードではこのように実装しました。

credentials = 'XXXXXXXXXXX' # 先ほど取得したユーザーのAPIトークン

TimeTreeApiClient.calendars(credentials)

# TimeTreeのAPIクライアント
class TimeTreeApiClient
  class << self
    # カレンダーの一覧を取得する
    def calendars(credentials)
      end_point = '/calendars'
      uri = api_uri(end_point)
      request = initialized_get_request(uri, credentials)
      response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

      # クラスに詰め替える
      JSON.parse(response.body)['data'].map do |calendar|
        Calendar.new({ id: calendar['id'], name: calendar['attributes']['name'] })
      end
    end

    private

    def api_uri(end_point)
      base_uri = 'https://timetreeapis.com'
      URI.parse(base_uri + end_point)
    end

    def initialized_get_request(uri, credentials)
      initialized_request('Get', uri, credentials)
    end

    # API 共通のリクエストを初期化する
    def initialized_request(method, uri, credentials)
      request = Net::HTTP.const_get(method).new(uri)
      request['Content-Type'] = 'application/json'
      request['Authorization'] = "Bearer #{credentials}"
      request['Accept'] = 'application/vnd.timetree.v1+json'

      request
    end
  end
end

レスポンスボディのdataに配列の形式でユーザーのカレンダーの一覧が格納されており、その要素の一つ一つにidが割り当てられています。
サンプルコードではCalendarというクラスを用意して、そのプロパティとしてidを定義しています。

class Calendar
  ACCESSORS = %i[id name]
  attr_accessor *ACCESSORS

  def initialize(params)
    ACCESSORS.each do |accessor|
      send("#{accessor}=", params[accessor])
    end
  end
end

サンプルコードでは便宜上クラスを定義しましたが、カレンダーIDが取得できていれば問題ありません。
次は取得したカレンダーIDに対して予定の作成を行っていきます。

予定の作成

下記のエンドポイントにPOSTリクエストを送信することで予定の作成が可能です。
/calendars/${予定を作成する対象カレンダーのID}/events

このAPIでは、主に下記の項目が作成した予定に登録可能となっています。

  • 予定のタイトル
  • メモ(予定の説明文)
  • 開始時刻
  • 終了時刻
  • 場所
  • 外部URL
  • ラベル(カレンダーの色)

その他登録可能な項目についてはこちらに記載されています。

サンプルコードではこのように実装しました。

def create
  calendar_id = 'XXXXXXXXXXXX' # 取得したカレンダーのID
  title = 'カレンダーのタイトル'
  description = 'カレンダーの説明文'

  # Time.zoneは'Asia/Tokyo'想定
  start_at = Time.zone.parse('2023/08/11 20:00:00').localtime('+0900').iso8601 # フォーマット指定あり => 2021-07-01T00:00:00+09:00
  end_at = Time.zone.parse('2023/08/11 21:00:00').localtime('+0900').iso8601

  # 予定のモデル
  event = Event.new(
    title: title,
    description: description,
    start_at: start_at,
    end_at: end_at,
    start_timezone: 'Asia/Tokyo',
    end_timezone: 'Asia/Tokyo'
  )

  credentials = 'XXXXXXXXXXXX' # 取得したユーザーのAPIトークン

  # 予定の登録
  TimeTreeApiClient.create_event(credentials, calendar_id, event)
end

class TimeTreeApiClient
  class << self

   # カレンダーに予定を登録する
    def create_event(credentials, calendar_id, event)
      end_point = "/calendars/#{calendar_id}/events"
      uri = api_uri(end_point)
      request = initialized_post_request(uri, credentials)
      request.body = event.request_body_for_create

      response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

      raise StandardError.new(response.message) if response.code != '201' # 成功したらステータスコード201が返却される

      true
    end

    private

    def initialized_post_request(uri, credentials)
      initialized_request('Post', uri, credentials)
    end

    # APIのリクエストを初期化する
    def initialized_request(method, uri, credentials)
      request = Net::HTTP.const_get(method).new(uri)
      request['Content-Type'] = 'application/json'
      request['Authorization'] = "Bearer #{credentials}"
      request['Accept'] = 'application/vnd.timetree.v1+json'

      request
    end
  end
end

# カレンダーの予定
class Event
  ACCESSORS = %i[
    id
    title
    start_at
    end_at
    description
    start_timezone
    end_timezone
    all_day
    category
    label_id
    label_type
  ]
  attr_accessor *ACCESSORS

  def initialize(params)
    # 必須入力値
    attr_values = params.reverse_merge(
        all_day: false,
        category: 'schedule',
        label_id: 1, # ラベルの色。エメラルドグリーン
        label_type: 'calendar'
    )
    ACCESSORS.each do |accessor|
      send("#{accessor}=", attr_values[accessor])
    end
  end

  def request_body_for_create
    base_request_body.to_json
  end

  def request_body_for_update
    base_request_body.to_json
  end

  def base_request_body
    {
      data: {
        attributes: (ACCESSORS - %i[label_id label_type]).each_with_object([]){ |v, attr| attr << [v, send(v)] }.to_h,
        relationships: {
          label: {
            data: {
              id: label_id,
              type: label_type
            }
          }
        }
      }
    }
  end
end

注意点として、TimeTreeのカレンダーの日時は日本に合わせてUTC+9:00で表示されています。
そのため、予定の作成時にはタイムゾーンオフセットで+9:00を指定する必要があります。
また、予定の作成には必須入力項目が多いのでそちらもご注意ください。
サンプルコードでは必要な必須入力項目をEventクラスのイニシャライザで渡しています。

加えて、このAPIを実行した際のレスポンスボディのdata.idに予定のIDが格納されています。
レスポンスボディの詳細な仕様についてはこちらをご覧ください。
後述の予定の更新、削除の操作を試す場合は、このIDを控えておいてください。

サンプルコードの内容でイベントを作成すると、TimeTree上ではこのように反映されます。

更新

下記のエンドポイントにPUTリクエストを送信することでカレンダーの更新が可能です。

/calendars/:calendar_id/events/:event_id

また、更新が可能な項目は登録可能な項目と同一となっています。
サンプルコードでは、下記のように実装しました

# 予定の更新
def update

  calendar_id = 'XXXXXXXXXXXX' # 取得したカレンダーのID
  event_id = 'XXXXXXXXXXXX' # 更新の対象となるイベントのID
  title = 'カレンダーのタイトル'
  description = 'カレンダーの説明文'

  # Time.zoneは'Asia/Tokyo'想定
  start_at = Time.zone.parse('2023/08/11 20:00:00').localtime('+0900').iso8601 # フォーマット指定あり => 2021-07-01T00:00:00+09:00
  end_at = Time.zone.parse('2023/08/11 21:00:00').localtime('+0900').iso8601

  # 予定のモデル
  event = Event.new(
    title: title,
    description: description,
    start_at: start_at,
    end_at: end_at,
    start_timezone: 'Asia/Tokyo', 
    end_timezone: 'Asia/Tokyo'
  )

  credentials = 'XXXXXXXXXXXX' # 取得したユーザーのAPIトークン

  TimeTreeApiClient.update_event(credentials, calendar_id, event)
end

class TimeTreeApiClient
  class << self
    # カレンダーの予定を更新する
    def update_event(credentials, calendar_id, event)
      end_point = "/calendars/#{calendar_id}/events/#{event.id}"
      uri = api_uri(end_point)
      request = initialized_put_request(uri, credentials)
      request.body = event.request_body_for_update

      response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

      raise StandardError.new(response.message) if response.code != '200' # 成功したらステータスコード200が返却される

      true
    end

    private

    def initialized_put_request(uri, credentials)
      initialized_request('Put', uri, credentials)
    end

    # APIのリクエストを初期化する
    def initialized_request(method, uri, credentials)
      request = Net::HTTP.const_get(method).new(uri)
      request['Content-Type'] = 'application/json'
      request['Authorization'] = "Bearer #{credentials}"
      request['Accept'] = 'application/vnd.timetree.v1+json'

      request
    end
  end
end

# カレンダーの予定
class Event
  ACCESSORS = %i[
    id
    title
    start_at
    end_at
    description
    start_timezone
    end_timezone
    all_day
    category
    label_id
    label_type
  ].freeze
  attr_accessor *ACCESSORS

  def initialize(params)
    # 必須入力値
    attr_values = params.reverse_merge(
      all_day: false,
      category: 'schedule',
      label_id: 1, # ラベルの色。エメラルドグリーン
      label_type: 'calendar'
    )
    ACCESSORS.each do |accessor|
      send("#{accessor}=", attr_values[accessor])
    end
  end

  def request_body_for_update
    base_request_body.to_json
  end

  def base_request_body
    {
      data: {
        attributes: (ACCESSORS - %i[label_id label_type]).each_with_object([]){ |v, attr| attr << [v, send(v)] }.to_h,
        relationships: {
          label: {
            data: {
              id: label_id,
              type: label_type
            }
          }
        }
      }
    }
  end
end

event_idEventクラスの引数に渡していることと、APIのエンドポイント以外はほぼ予定の作成時と変わりません。
所感ですが更新に必要な必須入力値が多いので、差分のみの更新は難しいなという印象でした。

予定の削除

下記のエンドポイントにDELETEリクエストを送信することで、作成したイベントの削除が可能です。
/calendars/:calendar_id/events/:event_id

サンプルコードでは下記のように実装しました。

def delete
  credentials = 'XXXXXXXXXXXX' # 取得したユーザーのAPIトークン
  calendar_id = 'XXXXXXXXXXXX' # 取得したカレンダーのID
  event_id = 'XXXXXXXXXXXX' # 更新の対象となるイベントのID

  TimeTreeApiClient.delete_event(credentials, calendar_id, event_id)
end

class TimeTreeApiClient
  class << self

    # カレンダーの予定を削除する
    def delete_event(credentials, calendar_id, event_id)
      # リクエストの組み立て
      end_point = "/calendars/#{calendar_id}/events/#{event_id}"
      uri = api_uri(end_point)
      request = initialized_delete_request(uri, credentials)
      response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }

      raise StandardError.new(response.message) if response.code != '204' # 成功したらステータスコード204が返却される
      true
    end

    private

    def initialized_delete_request(uri, credentials)
      initialized_request('Delete', uri, credentials)
    end

    # APIのリクエストを初期化する
    def initialized_request(method, uri, credentials)
      request = Net::HTTP.const_get(method).new(uri)
      request['Authorization'] = "Bearer #{credentials}"
      request['Accept'] = 'application/vnd.timetree.v1+json'

      request
    end
  end
end

APIトークン、カレンダーID、イベントIDを渡すだけなのでかなりスッキリしています。
これで予定の削除は完了です。

まとめ

最初にカレンダーIDを取得する手間はかかりますが、思ったよりも簡単にRailsからTimeTreeの予定の操作を行うことができるなという印象でした。
こちらの記事を参考に、是非みなさんも試してみてもらえると嬉しいです!

採用情報はこちら
目次