こんにちは、株式会社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
前提条件
- TimeTreeアカウントを登録していること
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リクエストには認可コードの他にもリクエストボディに下記を含む必要がありますのでご注意ください。
client_id
→ TimeTreeから発行されたクライアントIDclient_secret
→ TimeTreeから発行されたクライアントシークレットgrant_type
→authorization_code
redirect_uri
→ OAuth App作成時に登録した、リダイレクトURI
詳細はこちらに記載されています。
では取得したAPIトークンを使って、いよいよTimeTreeの予定の操作をしていきましょう!
基本仕様
TimeTreeに予定の操作に関するリクエストを送信する際は基本的に下記の仕様を満たす必要があります。
-
ベースURI
https://timetreeapis.com
-
リクエストヘッダ
Accept
→application/vnd.timetree.v1+json
Authorization
→Bearer ${認可サーバーから受け取ったAPIトークン}
Content-Type
→application/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_id
をEvent
クラスの引数に渡していることと、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の予定の操作を行うことができるなという印象でした。
こちらの記事を参考に、是非みなさんも試してみてもらえると嬉しいです!