API GatewayでスーパーチャットワークAPIを作る

チャットワークのAPIがv2になりましたね！

* [2017/01/26 - チャットワークAPIバージョンアップのお知らせ](http://help.chatwork.com/hc/ja/articles/115000019401)

コンタクトの承認用APIが新しく追加されましたが、コンタクトで繋がるような人がいない僕には無用の長物でした。

・・・なんかこう謎のくやしさがありますね。
くやしいのでAPI Gatewayとチャットワークの非公開APIを組み合わせて、公開APIには存在しない機能を持ったスーパーチャットワークAPIを作ってやろうと思います。

# 注意点

チャットワークの非公開APIを使用しているため下記の注意が必要です。

* 筋の悪い使い方をすると通信の遮断、アカウント停止の可能性があります
* API仕様が突然変更される可能性があります

ひらたく言えば何かあっても泣かない精神が必要です。

# 構成

![Untitled (11).png](https://qiita-image-store.s3.amazonaws.com/0/5522/1327954a-103b-1a17-d49d-8c5da76d8adc.png)

構成としてはシンプルでAPI Gatewayを使用して、カスタム認証でチャットワークに認証し、その結果でAPIを利用できるか認可します。
認可されたらAPI GatewayのHTTP Proxyまたは、Lambdaを使ってチャットワークの非公開APIを叩く形になります。

# 認可・認証

チャットワークでは非公開APIを叩くためにはセッションID、トークンを取得する必要があります。
実際にチャットワークのログインのフローを確認すると

1. `/login.php`にメールアドレスとパスワードをPOSTしてログイン
2. ログインに成功すると302レスポンスが戻ってきて`/`にリダイレクトする
3. レスポンスのJavaScript中に非公開APIを叩くための情報が変数に設定される

というようなフローになっています。
この2.のタイミングでヘッダーの`Set-Cookie`からセッションIDを、3.のタイミングで変数に設定されたトークンを取得することができます。

このフローをカスタム認証を行うLambda Functionとしてコードに起こしていく形になります。

## カスタム認証を行うLambda Function

```js
'use strict';

console.log('Loading function');

var https = require('https');
var querystring = require('querystring');

exports.handler = (event, context, callback) => {
    // 1. ユーザーから受け取ったAuthorization Tokenからログイン情報を取得
    var authrizationString = new Buffer(event.authorizationToken, 'base64').toString();
    var email = authrizationString.split(':')[0];
    var password = authrizationString.split(':')[1].trim("\n");

    Promise.resolve().then(() => {
      // 2. /login.phpにログイン情報をPOST
      var postData = querystring.stringify({
        email: email,
        password: password,
        auto_login: "on",
        login: "ログイン"
      });
      var options = {
        host: "www.chatwork.com",
        port: 443,
        path: "/login.php?lang=ja&args=",
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'Content-Length': Buffer.byteLength(postData)
        }
      };
      return request(options, postData);
    }).then((res) => {
      // 3. レスポンスからセッションIDを取得
      if (res.statusCode === 200) { // ログインに成功した場合は302、失敗した場合は200が返る
        throw new Error('Failed login');
      }
      return res.headers['set-cookie'].map((cookie) => {
        var m = cookie.match(/^cwssid=(.*?);/);
        return m && m[1];
      })
      .filter((v) => !!v)
      .filter((x, i, self) => self.indexOf(x) === i)
      .pop();
    }).then((cwssid) => {
    // 4. 取得したセッションIDを使って/にGET
      var options = {
        host: "www.chatwork.com",
        port: 443,
        path: "/",
        method: 'GET',
        headers: {
          Cookie: 'cwssid=' + cwssid
        }
      };
      return request(options).then((res) => {
        // 5. レスポンスボディのJavaScriptから非公開APIを叩くために必要な情報を取得
        var m1 = res.body.match(/ACCESS_TOKEN += +'(.+?)';/);
        var accessToken = m1 && m1[1];
        var m2 = res.body.match(/myid += +'(.+?)';/);
        var userId = m2 && m2[1];
        
        return {
          cwssid: cwssid,
          accessToken: accessToken,
          userId: userId
        };
      });
    }).then((credentials) => {
      // 6. 全ての処理に成功したらAPIへの認可と、後続のLambdaに渡す非公開APIを叩くための情報を返す
      var authResponse = {
        policyDocument: {
          Version: "2012-10-17",
          Statement: [
            {
              Action: "execute-api:Invoke",
              Effect: "Allow",
              Resource: event.methodArn
            }
          ]
        },
        context: {
          cookie: 'cwssid=' + credentials.cwssid + ';',
          accessToken: credentials.accessToken,
          userId: credentials.userId
        }
      };
      callback(null, authResponse);
    }).catch((e) => {
      // 7. 処理で失敗した場合、ログイン失敗ならAPIへの認可をせず、それ以外のエラーならエラーを返す
      if (e.message === 'Failed login') {
        var authResponse = {
          policyDocument: {
            Version: "2012-10-17",
            Statement: [
              {
                Action: "execute-api:Invoke",
                Effect: "Deny",
                Resource: event.methodArn
              }
            ]
          }
        };
        callback(null, authResponse);
      } else {
        console.log('ERROR:', e);
        callback(e);
      }
    });
};

function request(options, data) {
  return new Promise((resolve, reject) => {
    var req = https.request(options, (res) => {
      res.body = '';
      res.setEncoding('utf8');
      res.on('data', (chunk) => res.body += chunk.toString());
      res.on('end', () => {
        resolve(res);
      });
    });
    req.on('error', (e) => {
      reject(e);
    });
    data && req.write(data);
    req.end();
  });
}
```

今回は面倒だったので、AuthorizationTokenを旧npm方式の`email:password`をbase64化したものにしています。
セキュリティ面で気になる人は別の方式に切り替えても良いと思います。

また、上記実装では通常の非公開APIを叩き方と少し違うため、チャットワークの中の人に補足される可能性があります。
そこで、より良い実装を考えると

* APIを叩く度にログイン処理が発生するので、ログイン結果をキャッシュしてチャットワークへの問い合わせ回数を減らす
* リクエストヘッダがブラウザからのリクエストと違うので偽造する

などを考慮して実装を変えると良いです。

# チャットワークの非公開APIへアクセス

カスタム認証を行うLambda Functionを作成できたら、それを利用してチャットワークの非公開APIへアクセスするエンドポイントを作成します。
今回は公開APIでは取得できない、ルームへの招待リンクを取得するエンドポイント例に解説します。

## エンドポイントの作成

![image](https://qiita-image-store.s3.amazonaws.com/0/5522/b2bffc80-d2aa-0973-9efb-379b39cb5220.png)

まずはAPI GatewayでAPIを作成します。
今回はtestという名前にしますが、API名はわかりやすい名前であれば何でも構いません。

APIができたら次にエンドポイントのリソースの作成を行います。
今回はルーム情報の招待リンクを取得するエンドポイントなので、`/rooms/{roomId}/link`というリソースを作成します。

![image](https://qiita-image-store.s3.amazonaws.com/0/5522/9f11f6f8-e2f6-a45e-4c74-3e700745416f.png)
![image](https://qiita-image-store.s3.amazonaws.com/0/5522/f5df640a-7e17-bfee-0951-dbad0ae7da78.png)
![image](https://qiita-image-store.s3.amazonaws.com/0/5522/27a8e489-5141-778a-a289-28b2459a482f.png)

API Gatewayでは一括で階層を作ることができないため、1階層毎に追加していきます。

![image](https://qiita-image-store.s3.amazonaws.com/0/5522/49c5726a-75ee-46ee-8f62-06433eb02d71.png)

そして、最後にメソッドを指定します。
今回はリソースの取得なのでGETメソッドにしています。

## Authorizerの設定

エンドポイントが作成できたら、カスタム認証を行うLambda FunctionをAuthorizerに登録します。

![image](https://qiita-image-store.s3.amazonaws.com/0/5522/871f5ae7-28c9-0d99-4829-24a69d9d6b6b.png)

先に作成したLambda Functionを指定して、適当な名前（今回はAuthorizer）を指定します。
Execution Roleは今回はCloudWatch logsへの書き込みさえ出来れば良いので、デフォルトで作成されているlambda_basic_executionロールで構いません。
もし、別リソースを使ったキャッシュの仕組みなどを作っている場合は、そのリソースへの操作を許可したIAM Roleを指定してください。

## チャットワークの非公開APIへのリクエストのマッピングを作成

次にエンドポイントへのアクセスをチャットワークの非公開APIへのアクセスに変えるようにマッピングを作成します。

まずはMethod Requestに先ほど設定したAuthorizerをAuthorizationに追加します。

![image](https://qiita-image-store.s3.amazonaws.com/0/5522/ef6b3a25-4b28-eba2-5400-6f88148f28fe.png)

次にIntegration Requestにチャットワークの非公開APIへのアクセスを設定していきます。

![image](https://qiita-image-store.s3.amazonaws.com/0/5522/cce30a1e-5182-cba9-08d9-b56b198e47c4.png)
![image](https://qiita-image-store.s3.amazonaws.com/0/5522/7d389a30-a819-6a28-5a31-486eb1adf098.png)
![image](https://qiita-image-store.s3.amazonaws.com/0/5522/fd3c45a3-2525-b3ea-aac8-327fbddcefdb.png)
![image](https://qiita-image-store.s3.amazonaws.com/0/5522/cb647e60-0aaf-c6ee-db52-963dfd6728fb.png)
![image](https://qiita-image-store.s3.amazonaws.com/0/5522/5b52cbb3-f9a8-3f7f-51a0-6f2ebf25eb3f.png)

どういったマッピングが必要になるかは実際にチャットワークの通信内容をChromeのDeveloper Toolsなどで確認してください。
だいたいはURL Query String Parametersの`cmd`とBody Mapping Templatesのリクエストパラメーターを弄るだけで上手く行きそうな雰囲気はあります。（もちろん例外はあると思います）

ここまで出来たら一度APIをデプロイして実行してみてください。（認証が必要なのでテスト実行は上手く動かない）
特に問題なければ無事にチャットワークの非公開APIを叩くことができたと思います。

もし、うまく動かない場合はLambda Function、API Gateway、チャットワークの非公開APIとレイヤーを分けて、それぞれが想定通りに動いているのか確認してください。

## チャットワークの非公開APIのレスポンスのマッピングを作成

実際にAPIを叩けるようになりましたが、レスポンスの形が気に食わないです。
そこで、Response Integrationを指定して好みのレスポンスの形に変更します。

![image](https://qiita-image-store.s3.amazonaws.com/0/5522/ad3b00cb-100f-6878-09d7-cecbc2b4e320.png)

API GatewayのMapping TemplateはApache VTLで書くことができます。
条件式やループなども記載できるので、自分好みのレスポンスを作ってください。

## デプロイしたAPIの設定

エンドポイントの作成できたらデプロイをして動くようにします。

![image](https://qiita-image-store.s3.amazonaws.com/0/5522/d27e0cc4-d5f1-8581-baf9-2017f4eb3c09.png)

設定は用途に合わせて自由にして構いませんが、Throttlingの設定は必ずするようにしましょう。
仮にプログラムミスで大量アクセスが発生した場合、中の人に怒られるのでなるべくRateを低めにしておくと良いと思います。

# おわりに

API Gatewayの勉強がてら試してみたのですが意外にやれるもんですね。
とりあえず、これでBOTからチャットワークに画像アップロードやダウンロードなどいろいろとやりたかったことがやれそうです。

気が向いたらSAMに落とし込もうかなと思います。



この記事は中の人から警告が来たら消すのでご注意ください。