3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Rustに入門する〜3日目 Google Calendar APIで今日の予定を取得する〜

Last updated at Posted at 2020-04-29

初めに

2日目の続きです。

ちょっとRustがわかってきてあれgoogle-calrndar3これreqwestでも使えない?
って思ったんですがgoogle-carlndar3を突っ込むとopenssl-sysのコンパイルが行われてるのをみて諦めました。

3日目と言ってますが実際は数日かけて作ってます。

Google Calndar APIを使う

認証のための事前準備

Google Calnder APIのリファレンスは以下のようです
https://developers.google.com/calendar/v3/reference

とりあえずリファレンス見てそのままcurlで叩いてみようと思ったら401が出ました。
まぁ認証なしで自由に予定取れるわけないよねーって思って、
調べたらまさかのGCPのサービスの一環。

これなら全てGCPでやればいいのでは...と少し思いましたが、
メインどころは慣れてるAWSで一旦やろうと開き直りました。

GCPのアカウントはQwiklabsのこれやって資格受ければ割引あるよ!(結局受けてはないんですが)
ってのにつられて去年の夏頃に登録してるのでそれを使います。登録手順は省略です。

以下のサイトわかりやすかったので参考に進めました。

Google Calendar API と PHP で 予定の取得と追加をしてみるよ(準備編)
https://liginc.co.jp/472637

とりあえずAPIを有効化して認証情報は「サービス アカウント」で作成すればいいみたいです。
ざっとみですがAWSでいうところのIAM Roleみたいなものでしょうか。

サービス アカウントについて
https://cloud.google.com/iam/docs/understanding-service-accounts?hl=ja

こんな感じのJSONが取れました。
キーどころか諸々のUrlまで一式入ってました。ありがたいことです。

{
  "type": "service_account",
  "project_id": "xxxxxxx",
  "private_key_id": "xxxxxxxx",
  "private_key": "xxxxxx",
  "client_email": "xxxxxxxx",
  "client_id": "xxxxxxxxx",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "xxxxxxxxxxx"
}

JWTを利用して認証をしてみる

サービスアカウントを使う場合はこの認証方式になるみたいです。
JWTという形式は初めて知りましたが認証に使うフォーマットってだけでJWT認証って名前ではなさそうです。
なんて読んだらいいんだ...

JWTの作成には以下を利用しました。

jsonwebtoken
https://github.com/Keats/jsonwebtoken

こんな感じのコードを書いたらアクセストークンは取れました
呼び出し元はGoogle Calendar APIの呼び出しと合わせて記載します。

google_auth.rs
extern crate serde;
extern crate jsonwebtoken;
extern crate chrono;
extern crate reqwest;

use std::fs::File;
use std::io::BufReader;

use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, Header, Algorithm, EncodingKey};
use chrono::Local;

//second
const TOKEN_PERIOD: i64  = 300;

#[derive(Debug, Serialize, Deserialize)]
pub struct AccessTokenResponse {
    pub access_token: String,
    pub expires_in: i64,
    //pub token_type: String
}

#[derive(Debug, Serialize, Deserialize)]
struct SecretJson{
  //type: String,
  //project_id: String,
  //private_key_id: String,
  private_key: String,
  client_email: String,
  client_id: String,
  //auth_uri: String,
  token_uri: String,
  //auth_provider_x509_cert_url: String,
  //client_x509_cert_url: String
}


#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    iss: String,
    scope: String,
    aud: String,
    exp: i64,
    iat: i64
}

pub async fn get_access_token(secret_path: String,service_uri: String) -> AccessTokenResponse {

    let file = File::open(secret_path).unwrap();
    let auth_info: SecretJson = serde_json::from_reader(BufReader::new(file)).unwrap();
    let token_uri = auth_info.token_uri.clone();

    let jrt = generate_jwt(auth_info,service_uri);

    //let response = 
    let response = reqwest::Client::new()
        .post(&token_uri)
        .form(&[
            ("grant_type","urn:ietf:params:oauth:grant-type:jwt-bearer"),
            ("assertion",&jrt)
        ]).send().await.unwrap().text().await.unwrap();
    
    serde_json::from_str(&response).unwrap()
}

fn generate_jwt(auth_info: SecretJson, service_uri: String) -> String {
    let claims = Claims::new(auth_info.client_email, auth_info.token_uri, service_uri);
    let secret_key = EncodingKey::from_rsa_pem(str::as_bytes(&auth_info.private_key)).unwrap();
    return match encode(&Header::new(Algorithm::RS256), &claims, &secret_key) {
        Ok(t) => t,
        Err(e) => panic!(e.to_string())
    }
}

impl Claims{
    fn new(email: String, token_uri: String, service_uri: String) -> Self{
        let now: i64 = Local::now().timestamp();

        Claims {
              iss : email,
              scope : service_uri,
              aud : token_uri,
              iat : now,
              exp : now + TOKEN_PERIOD 
        }
    }
}

微妙に詰まったところ

他にもありましたがエラーメッセージだけだと解決しなかった部分です

秘密鍵の読み込み

サンプルに基づいてfrom_secret()で鍵を読みましたがInvalidAlgorithmのエラーを返しました。
from_seacret()はHMAC系のアルゴリズムの時に使うみたいです。今回はRSAなのでfrom_rsa_pemを利用しました。
少しづつですがRustを読めるようになってきたのでエラーメッセージから判定箇所を見つけてソースコードを追って解決しました。

アクセストークンの発行時のエラー

トークンの発行時刻と期限を逆にしてしまってた時のエラーは以下でした。
ググった感じこのエラーが出る場合はPCの時刻が合っていない等時刻系の問題が多いみたいです。

{"error":"invalid_grant","error_description":"Invalid JWT Signature."}

同一フォルダの別ファイルの読み込み

クレートを作ってその上でモジュールを読み込むと言うことは今までしてましたが、
同じフォルダに置いたA.rsからB.rsを読み込む手段がわからず地味に引っかかりました。

以下のどちらかで良いみたいです。
違いは指定パスが相対か絶対かという点らしいです。

use crate::google_auth;
use self::google_auth;

Google Calendar APIで自分の予定を取得する

さて、本命です。
予定の一覧を取得するにはEventのlistを叩けば良さそうです。

Events: list
 https://developers.google.com/calendar/v3/reference/events/list

こちらも色々やっつけな部分が多くありますが
以下の通りに実装しました。

いくつか解決してない問題もあります。

extern crate reqwest;
extern crate chrono;
extern crate serde;

use crate::google_auth;
use google_auth::AccessTokenResponse;

use reqwest::header;
use chrono::{Local, Date};
use serde::{Deserialize, Serialize};

const READONLY_CALENDER_URI: &str = "https://www.googleapis.com/auth/calendar.readonly";

////{} => calendar id
//const CALENDAR_EVENT_LIST_URL: &str = "https://www.googleapis.com/calendar/v3/calendars/{}/events";

#[derive(Debug, Serialize, Deserialize)]
pub struct CalendarEvent{
    pub items: Vec<EventItem>
}

#[derive(Debug, Serialize, Deserialize)]
pub struct EventItem{
    pub summary: String,
    pub originalStartTime: Option<OriginalStartTime>,
    pub start: EventItemPeriod,
    pub end: EventItemPeriod
}

#[derive(Debug, Serialize, Deserialize)]
pub struct EventItemPeriod {
    //unused when all day schedule
    pub dateTime: Option<String>,
    //unused when not all day schedule
    pub date: Option<String>
}


#[derive(Debug, Serialize, Deserialize)]
pub struct OriginalStartTime{
    pub dateTime: String
}

pub async fn get_oneday_schedule(email: String, oneday: Date<Local>) -> CalendarEvent {
    let token:AccessTokenResponse = google_auth::get_access_token("./secret.json".to_string(),READONLY_CALENDER_URI.to_string()).await;

    let mut headers = header::HeaderMap::new();
    headers.insert(header::AUTHORIZATION, header::HeaderValue::from_str(&format!("OAuth {}",token.access_token)).unwrap());
    
    let response = reqwest::ClientBuilder::new()
        .default_headers(headers)
        .build().unwrap()
        .get(&format!("https://www.googleapis.com/calendar/v3/calendars/{}/events",email))
        .query(&[
            ("timeZone","jst"),
            ("timeMin",&oneday.and_hms(0,0,0).to_rfc3339()),
            ("timeMax",&oneday.and_hms(23,59,59).to_rfc3339())
        ])
        .send().await.unwrap().text().await.unwrap();
    //println!("{}",response);
    serde_json::from_str(&response).unwrap()
}

pub async fn get_today_schedule(email: String) -> CalendarEvent {
    get_oneday_schedule(email, Local::today()).await
}

とりあえず今回の用途として今のところ最低限必要と考えているのは、
イベントのタイトル、そのイベントが終日であるかどうかの2点です。
後者についてはデータを実際に見た感じでは
events内のstartdateTimeが含まれているか、dateが含まれているかで判別できそうなのでこれを取得します。

また最初いくつかの予定が多重に出力されていたので、
原因を調べていたところ繰り返しの予定の場合、
繰り返しの元となるスケジュールのようなものと、実際のスケジュールで別々に取れるみたいです。
originalStartTimeの有無で判別できるみたいです。
-> singleEvents=Trueとしてやれば繰り返しイベントのベースを除いた状態で取得できるみたいです(追記:2020/05/03)。

CalendarIDは自分のカレンダーがメールアドレスそのままだったので指定していますが変数名をemailとしていますが
多分メールアドレスではないケースもあると思います。

呼び出し元はこんな感じです。
とりあえずタイトルと、開始時間をとりました。
開始時間の部分はとりあえず動くのをみたかったのでだいぶチェックが甘いです。

let event_list = google_calendar::get_today_schedule("hogehoge@gmail.com".to_string());
for item in event_list.await.items {
    println!("summary:{},start:{}",
        item.summary,
        match item.start.dateTime{
            Some(d) => d,
            None => item.start.date.unwrap()
    });
}

出力は以下の通りです。
item.originalStartTime .is_none()で単純にやってしまうとスケジュールではない通常の予定も落としてしまいます。
実際データの選別についてはまた別のタイミングで実装するので、今回は仮に見るだけです。

~~これは本当にわからないんですが、なぜか翌日の終日スケジュールが取れます。~~なんで。
解決しました。別項に書いてます。

summary:xxx,start:2020-03-30T10:30:00+09:00 // item.originalStartTime .is_none()の場合の値
summary:xxx,start:2020-04-29T10:30:00+09:00 
summary:yyy,start:2020-03-26T18:00:00+09:00 // item.originalStartTime .is_none()の場合の値
summary:yyy,start:2020-04-29T18:00:00+09:00
summary:zzz,start:2020-04-30                // なぜか取れる翌日の終日スケジュール

未解決事項(or未理解事項)

OAuth2.0認証について

さて、データは取得できたもののこちらの件についての理解が追いついていません

アクセストークン取得後の認証を行う際にどこにaccess_tokenを入れれば良いのかと言う点に悩みました。
ちょっとした短い英文なら読めますがこのGoogle APIのリファレンスの膨大な量をあてをつけず読むのはなかなか厳しいです。
(ゆっくり読めば読めますがものすごく時間がかかります)

気持ちとしてはとりあえず実装したいお気持ちがあったので参考サイトを探したところ以下に出会いました。

JWTを使ってGoogleAPIのアクセストークン取得する
https://dream-soft.mydns.jp/blog/developper/housework/2019/10/377/

どうやらAuthorizationヘッダーをつければ良いみたいです。

Authorization
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Authorization

どうやらこれについてはGoogle APIでの仕様というよりOauth認証としての仕様なようです。

ただ、これについて調べた際にRFC9749やRFC9750のような近いものは見つかるのですが、
Authorization: OAuthを利用するRFCが見つかっていません(一応Oauth1.0でそれっぽいのがありますが。

一旦こちらの件については後日記事を捜索するとともに改めて、
OAuth認証について理解を得ようと思います。

なぜか翌日の終日スケジュールが取れてしまう

上記のコード実行例あたりに記載した通りです。
なぜか取れるのでAPI仕様書等と睨めっこしている最中です。

timeZone周りの問題とかでてUTC/JSTのズレとかこっちがミスってるとかで解決してくれれば楽ですが...祈ります。

解決(2020/05/03追記)

chrono::Local::today()をしたらJSTでとれると思ってたんですが、
おそらく実行環境のコンテナのタイムゾーンがUTCなので2020-05-03T00:00:00+00:00で取れてました。

template.yaml に対して環境変数でタイムゾーンを指定すればプログラム側の変更は不要ですが、
AWS LambdaTZは予約変数らしいのであまり使わない方がいいらしいです。
なのでRust側で指定します。

AWS Lambda 環境変数の使用
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime

      Environment:
        Variables:
          TZ: "Asia/Tokyo"

chrono-tzとやらもあるみたいですが
一発目の設定でJSTとしたいだけなので雑にtoday().with_timezone(&FixedOffset::east(JST_OFFSET))で取得するようにします。
(JST_OFFSETconst JST_OFFSET: i32 = 9 * 3600;として定義)
逆にカレンダー撮るときにUTCを指定しても解決した気はしますが、
時刻を見ようとするといちいち変換しないといけないのでまだこっちの方がしょらい性がありますね。

3日目を終えて

コンパイルエラーと睨めっこしたりdocs.rsでリファレンスと睨めっこしたり、
ようやくRustを書き始めたんだと言う実感が出てきました。

Rustはコンパイルエラー出した時に、
ちなみにだけど元の関数の定義はここでこれだよ!とか
この型これだからこうしたらいいんじゃない?(大体&strString問題)とか、
結構親切に教えてくれるのでものすごく親切で感動しています。

最初&strStringの違いを意識できていなくてほぼほぼStringで書いてしまったり(途中で調べて理解しました)、
jsonのデシリアライズのために変数名が一部LowerCamelになっていたり、
unwrap()地獄になっていたり...etc
と、動くけど体裁的に良くないよねという部分がそれなりにあります。

なので次に進むよりある程度この辺をきれいにしてから次に行こうと思います。

Googleがライブラリを提供しているのもあって、
ライブラリを用いた実装をしているのサイトが多く、
なんとかなるとは思わなってなかったです(英語もう少し読めたら...)。
APIのライブラリも結構しっかりしていたのも救いでした。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?