AWS LambdaからGoogle APIを呼び出す

こんにちは、AWS担当のwakです。間が空いてしまったので、今回は簡単な記事を書いて隙間を埋めることにします。

f:id:nurenezumi:20160428191057j:plain

背景

世はSlackが大流行ですが、未だにエラー通知メールという仕組みも残っており、これを無視するわけにはいきません。そこで、Gmail(Google Apps)に届いたエラーメールを担当者がいるSlackのチャンネルに流す連携を作りました。今回はGoogle APIを呼ぶための仕組みと、AWS Lambdaから呼び出す手順について説明します。

何をするか

次のことをやります。

  1. Google APIを呼び出すアプリケーションを作り、ユーザーのGmailの情報を読み取れるようにする
  2. 自分のユーザーGoogleログイン→OAuth認証を行い、トークンを取得する
  3. トークンはDynamoDBに入れておく
  4. トークンは1時間で有効期限が切れてしまうため、Lambdaで自動的に更新する
  5. このトークンを使って何かする

プロジェクトの準備

プロジェクト作成

まずGoogle Developers Consoleにログインし、画面右上のメニューから「プロジェクトの作成」を行います。「概要」>「Google API」から、このアプリケーションが呼び出したいAPIを選択します。ここではGmail APIを選択しました(複数選べます)。

f:id:nurenezumi:20160426174114p:plain

APIが選択できたら「認証情報」から「認証情報を作成」をクリックし、「OAuthクライアントID」を選択しましょう。

f:id:nurenezumi:20160426174046p:plain

OAuth2.0のおさらい

ここで簡単にOAuth2.0のおさらいをします。一般にWebサービスが提供するOAuth認証を利用するアプリケーションは、

  • ユーザーが許可した権限でWebサービスAPIを利用することができる
  • APIを呼び出すときには、認証時に発行されたキーをリクエストに含める(ユーザーのIDやパスワードは分からない)
  • このキーには有効な寿命が設定されていることがある
  • 有効期限がある場合、認証時に与えられたリフレッシュトークンを使ってリクエスト時に更新しなければならない
    • 定期的に更新しても構わない

という流れなのでした(そしてGoogleOAuth認証では寿命が設定されています)。これを念頭に次のステップへ進みます。

認証画面設定

OAuthクライアントIDを作るためにはまず「同意画面」の設定が必要となります。これはユーザーに「このアプリケーションに権限を与えますか?」という確認を提示するための画面で、ユーザーがその可否を判断するために必要な連絡先やアプリケーション名を記入しておきます。

f:id:nurenezumi:20160426174024p:plain

最後に「アプリケーションの種類」として「その他」を選んで「作成」をクリックすれば完了です。その場でクライアントIDとクライアントシークレットが発行されます。これは後からでも確認できるのでメモを取る必要はありません。

f:id:nurenezumi:20160426174413p:plain

認証&トークン取得

初回認証

認証に使うアカウントでGoogleにログインした状態で、次のURLをブラウザから開きます(改行と空白は削除して1行にしてください)。

https://accounts.google.com/o/oauth2/v2/auth
  ?scope=https://www.googleapis.com/auth/gmail.readonly%20https://www.googleapis.com/auth/gmail.labels
  &redirect_uri=urn:ietf:wg:oauth:2.0:oob
  &response_type=code
  &client_id=999999999999-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com
  • scopeにはこのアプリケーションが要求するスコープ(下述)をスペース(%20)区切りで並べます。
  • redirect_urlは固定値です。ここでは認証後に外部へは遷移せず、認証コードを表示する画面を表示することを指示しています。詳しくはドキュメントを参照してください。
  • client_idには最初に取得したクライアントIDを書きます。
  • approval_prompt, access_typeを指定すると書いてある記事も多くありますが、現時点では必要ないようです。

スコープは様々な権限を適当な粒度で丸めたものです。たとえばメールを読み取る権限が必要なだけならhttps://www.googleapis.com/auth/gmail.readonlyと書けば良いのですが、メールにラベルを追加・削除したければhttps://www.googleapis.com/auth/gmail.labelsも指定しなければなりません。Gmailについてのスコープはドキュメントに一覧があります。

このURLを開くと、画面には権限のリクエストを許可するか拒否するかの選択肢が表示されます。

f:id:nurenezumi:20160426191729p:plain

要求されている権限は確かにスコープで指定したものであることが分かります。ここで「許可」を押すと秘密のコードが表示されます(これはWebページのタイトルにも出力されています)。このコードこそが、ユーザーがアプリケーションに権限を与えたという証拠になります。

f:id:nurenezumi:20160426175521p:plain

トークン2種類を取得

このコードを含めて再度リクエストを送るとaccess tokenとrefresh tokenが取得できます。curlで次のコマンドを叩きます。

curl -d client_id=999999999999-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com \
     -d client_secret=xxxxxxxxxxxxxxxxxxxxxxxx \
     -d grant_type=authorization_code \
     -d redirect_uri=urn:ietf:wg:oauth:2.0:oob \
     -d code=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
     https://accounts.google.com/o/oauth2/token

client_id, client_secretはプロジェクト作成時に貰った値、codeは今ブラウザに表示された値です。うまく行けばJSON形式で結果が返ります。

{
  "access_token" : "xxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "token_type" : "Bearer",
  "expires_in" : 3600,
  "refresh_token" : "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

欲しかったaccess tokenとrefresh tokenが取得できました!

APIコールを試す

権限を手に入れたので、後はAPIを好きに叩くことができます。GmailAPI一覧はドキュメントにありますので、たとえばこのコマンドではGmailで作成済みのラベル一覧を出力します。エンドポイントの中にあるuserIdmeに置き換えておくのを忘れないでください。

curl -H "Authorization: Bearer xxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    https://www.googleapis.com/gmail/v1/users/me/labels

同様に、今年1/1以降で「猫」が含まれるメールを検索し、そのメールIDのリストを取得するのならこうなります。スペースはエスケープしてください。またqの引数にはGmailの検索クエリと全く同等のものが使えます。

curl -H "Authorization: Bearer xxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    https://www.googleapis.com/gmail/v1/users/me/messages?q="after:2016-01-01%20猫"

access token更新

ここで最も重要な値は上で得たrefresh tokenです。これさえあればいつでも新しいaccess tokenを取得(=更新)できるからです。これはアプリケーション側で保存しておく必要があります。

curl -d client_id=999999999999-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com \
     -d client_secret=xxxxxxxxxxxxxxxxxxxxxxxx \
     -d refresh_token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
     -d grant_type=refresh_token \
     https://accounts.google.com/o/oauth2/token

更新を行ったとしても、更新前に使っていたaccess tokenは有効なまま残ります。つまり、

  • 何か処理を行う前に必ず更新するようにする

    • refresh tokenさえ保存しておけばいい。ここで得たキーは1時間ずっと使える
  • N分ごとに自動更新するようにする(Nは30分、55分など)

    • access tokenをDBか何かに入れておく
    • N分ごとにcronなどでrefresh tokenを使ってaccess tokenを取得し、DBの中のaccess tokenを更新する
    • アプリケーションはDBから取得したaccess tokenで処理をする。このaccess tokenは最短(60-N)分で無効になる

といった選択肢ができることになります。自動処理ができるなら後者の方が簡単そうですね。

AWSの準備

トークンをDynamoDBに入れる

access tokenとrefresh tokenをDynamoDBに入れておきましょう。各種外部サービス*1APIを叩くためのキーを管理するためのテーブルを元から作ってあったので、そこに追加することにしました。このテーブルのプライマリキーはkeyにしています。

f:id:nurenezumi:20160427202441p:plain

更新用Lambdaを用意する

どこかのサーバーからcronでバッチを回してもいいのですが、ここはLambdaで作って自動実行することにします。何をやっているかを明確にするためにライブラリは使っていません。

github.com

このコードを実行すると、DynamoDBの指定したテーブル・レコードからrefresh tokenなどの値を取得してaccess tokenを更新し、レコードとタイムスタンプを更新します。DynamoDBへのアクセス権を与えなければならないことには気をつけましょう。

定期的に実行する

作成したLambdaを再度開き、Event sources>Add event sourceと進んで、表示されるダイアログでEvent source typeとしてCloudWatch Events - Scheduleを選択します。

f:id:nurenezumi:20160427205218p:plain

Schedule expressionとして「rate(30 minutes)」を選べば30分ごとに自動実行してくれるようになります。簡単ですね。

トークンを使って何かする

これで常に有効なaccess tokenがDynamoDBに入っている状態となりました。先ほどと同じように、Lambdaから今あるラベルの一覧を取得してログに出力してみます。

var https = require("https");
var querystring = require("querystring");
var AWS = require("aws-sdk");
var dynamo = new AWS.DynamoDB.DocumentClient();
var TABLENAME = "YOUR-DYNAMODB-TABLE-NAME";
var KEYNAME = "YOUR-RECORD-KEY";

var getGoogleApiKey = function(callback) {
    var condition = {
        TableName : TABLENAME,
        KeyConditionExpression : "#k = :val",
        ExpressionAttributeValues : {":val" : KEYNAME},
        ExpressionAttributeNames  : {"#k" : "key"}
    };
    dynamo.query(condition, function(err, data) {
        if (err) { context.fail(err); }
        else { callback(data.Items[0].token); }
    });
};

var kickGet = function(path, token, success, fail) {
    var options = {
        hostname: "www.googleapis.com",
        port: 443,
        path: path,
        method: "GET",
        headers : { "Authorization" : "Bearer " + token }
    };

    var req = https.request(options, function(res) {
        body = "";
        console.log("[DEBUG] response received");
        res.setEncoding("utf8");
        res.on("data", function(chunk) { body += chunk; });
        res.on("end", function() {
            if (res.statusCode == 200) {
                console.log(body);
                var json = JSON.parse(body);
                success(json);
            } else {
                console.error("ERROR!");
                console.error(body);
                console.error(res.errorMessage);
                fail(res);
            }
        });
    });
    
    req.on("error", function(err) { fail(err); });
    req.end();    
};

exports.handler = function(event, context) {
    getGoogleApiKey(function(token) {
        kickGet(
            "https://www.googleapis.com/gmail/v1/users/me/labels",
            token,
            context.succeed,
            context.fail
        );
    });
};
f:id:nurenezumi:20160428164945p:plain

結果はこんな感じです。無事出力に成功しました。

まとめ

OAuthのフロー図を見ると若干面倒くさいように感じられますが、実はこれだけのコードで実現可能なシンプルな処理で構成されていることが分かります。これを使って次はもっと複雑なことにチャレンジしてみたいと思います*2。それでは!

*1:Google、Slack、Salesforceなど

*2:実装よりも記事を書く方が大変だったりしますが