Lambda + API GatewayでGithub上のアクションを検知してGithubに対してアクションする

# はじめに
最近バックエンドの実装をメインに担当しているエンジニアです。
先日チームメンバー（@sen-higaさん）と共同で行った業務効率化タスクを通して、初めてWebhookやサーバレスアーキテクチャに触れたので、その時の備忘録です。

# やったこと
Github上のアクションを検知してGithubに対してアクションするという仕組みをLambda + API Gatewayで実装しました。
GithubからGithubへの動線がわかりやすいように、タスクの実装時とは内容を変えて、イシューがOpenされた時に、作成者をイシューに自動アサインするというシンプルな仕組みにしました。

<img width="878" alt="スクリーンショット 2019-12-19 8.56.08.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246955/997eaeb2-b283-b7dc-7b36-07651c4613e4.png">


もっとこうした方がいい、自分ならこう実装するというご意見があればぜひお願いします。

# 開発環境
os: mac High Sierra 10.13.6
npm: 6.11.3
Node: 12.12.0

# 利用技術
今回、利用したのはGithub Webhook Github API,AWS Lambda, Amazon API Gatewayになります。

## [Github webhook](https://developer.github.com/webhooks/)

Webhookとはあるサービスでのイベント発生時に、指定したURLにPOSTリクエストする仕組みです。（[webhookとは？](https://qiita.com/soarflat/items/ed970f6dc59b2ab76169)より）
Github webhookは、Githubが提供しているwebhookで、特定のアクションがリポジトリあるいは Organization で生じたときに外部の Web サーバーへ通知を配信する方法を提供しています。（[公式ドキュメント](https://help.github.com/ja/github/extending-github/about-webhooks)より）

例えば、イシューのwebhookを登録しておくと、イシューがopenした時に登録したエンドポイントに以下のようなリクエストが送られます。
<img width="567" alt="スクリーンショット 2019-12-18 16.59.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246955/39a4428e-fb8d-b09f-72a4-63235774bc1c.png">

([公式ドキュメント](https://developer.github.com/webhooks/#example-delivery)より）


## [Github API](https://developer.github.com/v3/)

Webhookとは逆にGithubにアクションする時に使います。APIを利用するには、アクションを実行させるアカウントでアクセストークンを発行する必要があります。

## [AWS Lambda](https://aws.amazon.com/jp/lambda/)

サーバーレスでコードを実行できるサービスです。管理画面で、コーディング、環境変数の管理、テストなど色々できます。

## [Amazon API Gateway](https://aws.amazon.com/jp/api-gateway/)

開発規模に応じたエンドポイントを簡単に用意できるサービスです。webhookがリクエストを投げるエンドポイントを作成するために利用しました。Lambdaと連携させると、ヘッダー、ボディをJSON形式でlambdaに渡してくれます。

# 実装の流れ(概要)

以下のような流れで実装しました。

1. Lambda関数の作成
2. エンドポイントの作成
3. Github Webhookの追加
4. Github API用のアクセストークンの取得
5. Lambda関数の実装
 1. リクエストのバリデーション
 2. Webhookのリクエストを解釈
 3. APIを叩けるようにモジュールを追加
 4. Github APIにリクエストを投げる 
6. 動作確認

# 実装の流れ（詳細）

## 1. Lambda関数の作成

コンソールから言語をnode.jsに指定して作成しました。

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

Designerの「トリガーの追加」からエンドポイントを追加しました。
「トリガーの追加」 -> 「API Gateway」を選択し、新規APIの作成画面を表示します。
今回はシンプルな動作のみ実装するのでテンプレートは「HTTP API」を選択しました。

作成後リダイレクトされたLambdaの画面下部に、デプロイ済みのエンドポイントが表示されます。こちらのエンドポイントをGithub Webhookに登録します。

## 3. Github Webhookの追加

### 3.1 Webhookに登録するトークンを生成

先程作成したエンドポイントは任意のリクエストを受け付ける状態になっているので、Webhookの認証用ヘッダーを見てリクエストを制限します。認証用ヘッダーをWebhookに埋め込んでもらうために、トークンを事前に用意しておきます。

今回は[公式ドキュメント](https://developer.github.com/webhooks/securing/#setting-your-secret-token) に従って生成したトークンを利用しました。

こちらのトークンは実装時にも利用するので、Lambda関数の環境変数に任意のキー名（SECRET TOKEN等）で追加しておきます。

### 3.2 Webhookの追加
先程作成したエンドポイントを使ってwebhookを追加します。
アクションを検知したいリポジトリに行き、 Settings -> Webhooks -> Add Webhook
から追加します。この時、先程生成したトークンをSecret欄に記入します。

また、今回はイシューのみを対象としたいので
「Let me select individual events」 -> 「issues」 にチェックを入れました。

<img width="744" alt="スクリーンショット 2019-12-19 7.42.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246955/06fa1eba-20a4-533e-1322-0307a16dfe48.png">


## 4. Github API用のアクセストークンの取得
### 4.1 Tokenの取得
Github API用のトークンも取得しておきます。
自分のアイコンマークを押すと表示されるメニュー -> Setting -> Developer settings -> Personal access tokens -> Generate New Token から追加します。

今回は「repo」の権限を与えたトークンを発行しました。
<img width="775" alt="スクリーンショット 2019-12-17 19.20.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246955/318eafa8-c144-9187-b6ac-74f9d07f7def.png">

発行されたTokenはLambdaの「環境変数」に任意のキー名（ACCSESS_TOKEN等）で登録しておきます。

これで、Githubからのアクションを検知し、Github APIを叩く動線が整いました。

## 5. Lambda関数の実装

以下のような手順で実装しました。

1. リクエストのバリデーション
2. 対象のアクションか判定
3. APIリクエスト用にモジュールを追加
4. GithubAPIにリクエストを投げる 

#### 5.1 リクエストのバリデーション

リクエストのバリデーションには、`X-Hub-Signature`ヘッダー、リクエストボディ、環境変数に登録した`SECRET_TOKEN`を利用します。

[公式ドキュメント](https://developer.github.com/webhooks/securing/#validating-payloads-from-github)によると、`X-Hub-Signature`は、鍵を`Secret Token`、データを`ボディ` として算出した値(MAC値)と一致します。

バリデーション実装部分のコードはこちらです。

```js
exports.handler = (event) => {
    const headers = event.headers;
    const body = event.body;
    if (! isValid(body, headers)) {
        const response = {
            statusCode: 500,
            body: 'Given signatue is invalid',
        };
        return response;
    }
    const response = {
        statusCode: 200,
        body: 'OK',
    };
    return response;
};

function isValid (body, headers) {
    const crypto = require('crypto');
    const hmac = crypto.createHmac('sha1', process.env.SECRET_TOKEN);
    hmac.update(body, 'utf8');
    const signature = 'sha1=' + hmac.digest('hex');
    return signature === headers['X-Hub-Signature'];
}
```

#### 2. 対象のアクションか判定する

次に、リクエストが対象のアクションのものか判定する部分を実装します。現状だと[issueに関わるあらゆるアクション（edited, deleted, transferred等）](https://developer.github.com/v3/activity/events/types/#issuesevent)に反応してしまうので、今回は`opened`のみ反応するようにします。

```js
exports.handler = (event) => {
    ...
    if (! isOpened(JSON.parse(body))) {
        const response = {
            statusCode: 400,
            body: 'Given action is invalid',
        };
        return response;
    }
    ...
};

function isValid (body, headers) {
    ...
}

function isOpened (body) {
    return body.action === 'opened';
}
```

これで、Webhookのリクエストを解釈する部分の実装は完了です。

#### 3. APIリクエスト用にモジュールを追加する
続いて、APIを叩く部分の実装に入る前に、リクエストを行えるよう[requestモジュール](https://github.com/request/request)を追加しておきます。
[公式の注記](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-create-deployment-pkg.html)に従って、`Layer`という機能を使ってmoduleを追加しました。

追加手順は、
1.ローカルPCで`request`モジュールを`npm install`
2.`node_modules`のzipファイルを用意（[zipファイルのディレクトリは「nodejs」が先頭です](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-layers.html#configuration-layers-path)）

 ```
 {ファイル名}.zip
 └ nodejs/node_modules/...
 ```

3.lambda画面左側の「Layer」からレイヤーを追加します
4.追加したレイヤーをlambda画面「Designer」の「Layers」から今回のlambdaと紐づけます
Layerで紐付けたモジュールはlambda関数から自由に利用できます。

#### 4. GithubAPIにリクエストを投げる 

APIにリクエストを投げる部分を実装します。
イシュー作成者をイシューにアサインする場合、ヘッダー、メソッド、URI、ボディは以下の
ように設定します。

##### ヘッダー

環境変数に追加したトークンを利用して[Authenticationヘッダー](https://developer.github.com/v3/#authentication)を、トークンを発行したユーザ名を`User-Agent`ヘッダーに追加します

##### URI
[イシューを更新する場合は](https://developer.github.com/v3/issues/#edit-an-issue)
`{ルートエンドポイント}/repos/:owner/:repo/issues/:issue_number`を指定します

#####　メソッド
[イシューを更新する場合は](https://developer.github.com/v3/issues/#edit-an-issue)`PATCH`を指定します

##### リクエストボディ
[JSON形式](https://developer.github.com/v3/issues/#example-1)でパラメータを記入します。(
今回はAssigneeを指定するパラメータ)

```js
exports.handler = (event) => {
    ...
    const request = require('request');
    request(params(JSON.parse(body)), (error, response, body) => {
        if (error) {
            console.error('Issue assign failed');
        } else {
            console.log('Issue assign success');
        }
    });
    ...
};

...

function params (body) {
    return {
        json: true,
        headers: {
            'Authorization': 'token ' + process.env.ACCESS_TOKEN,
            'User-Agent': 'yanagimura'
        },
        method: 'PATCH',
        uri: `${body.issue.repository_url}/issues/${body.issue.number}`,,
        json: {
            'assignee': 'yanagimura'
        }
    };
}
```

## 6. 動作確認

早速イシューを作成してみます。

イシューを立てた直後にはAssineesは空ですが
<img width="1056" alt="スクリーンショット 2019-12-18 17.37.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246955/41809956-bf9d-f9ba-c394-c4a1ec7d6739.png">

🔽

<img width="1048" alt="スクリーンショット 2019-12-18 17.37.09.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246955/33dc659f-0be2-7904-434c-ad7d405d3f26.png">

すぐにイシューを立てたユーザ（私）がアサインされました！

# まとめ
　
チームメンバーと共同で実装した時から時間が経っていたため、忘れている部分も多々あり、今回記事を投稿することで復習できてよかったです。ひとつひとつの実装はシンプルでしたが、それを組み合わせて、ひとつの流れを作ろうとすると、サーバレスとはいえ結構複雑だと感じました。　

また、ヘッダーの署名やAPIリクエストの構造などの知見は、外部サービスの利用と構築の両方の観点から得るものが多かったです。

# 最後に
全体のコードを記載しておきます。

```js
exports.handler = (event) => {
    const headers = event.headers;
    const body = event.body;
    if (! isValid(body, headers)) {
        const response = {
            statusCode: 500,
            body: 'Given signatue is invalid',
        };
    }
    if (! isOpened(JSON.parse(body))) {
        const response = {
            statusCode: 400,
            body: 'Given action is invalid',
        };
        return response;
    }
    const request = require('request');
    request(params(JSON.parse(body)), (error, response, body) => {
        if (error) {
            console.error('Issue assign failed');
            console.error(error);
        } else {
            console.log('Issue assign success');
            console.log(response);
            console.log(body);
        }
    });
    const response = {
        statusCode: 200,
        body: 'OK',
    };
    return response;
};

function isValid (body, headers) {
    const crypto = require('crypto');
    const hmac = crypto.createHmac('sha1', process.env.SECRET_TOKEN);
    hmac.update(body, 'utf8');
    const signature = 'sha1=' + hmac.digest('hex');
    return signature === headers['X-Hub-Signature'];
}

function isOpened (body) {
    return body.action === 'opened';
}

function params (body) {
    return {
        json: true,
        headers: {
            'Authorization': 'token ' + process.env.ACCESS_TOKEN,
            'User-Agent': 'yanagimura'
        },
        method: 'PATCH',
        uri: `${body.issue.repository_url}/issues/${body.issue.number}`,
        json: {
            'assignee': `${body.sender.login}`
        }
    };
}
```