Node.jsでREST APIを連続実行するプログラムを組んでみる（Google Sheet APIを利用)

## 1.はじめに

業務で以下のような簡易プログラムが欲しくて作りたいので、下調べのためお試し版を作ってみたので記載する。

(1) REST API (HTTPメソッド：GET) を実行
(2) (1)で取得したデータを元にリクエストボディ作成
(3) REST API (HTTPメソッド：POST or PUT or DELETE) を実行

使用するREST APIはなんでも良かったため、メジャーかつ使用するのに敷居が高くなさそうなのが良いと思い、Google Sheet APIを使用した。
実際は、REST APIを実行するために必要なアクセストークンの生成に必要な手順等で多少詰まったので、それもまとめておく。

## 2. Google API OAuth2.0 のトークン取得

**(1) Googleアカウント作成**

アカウントがなければ。または、Google API用に別アカウントを作るのであれば

**(2) Google Developers Consoleで新規プロジェクト作成**

[Google Developers Console](https://console.developers.google.com/) にアクセス

画面上部「Google APIs」のロゴの隣にあるプロジェクトの選択の部分から新しいプロジェクトを作成

**(3) Oauth Client ID 作成**

認証情報からクライアントIDを作成する

![OAuthClientID作成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319263/b64a0f40-cf3c-7023-8058-753538d9b75e.png)

作成したクライアントIDの右端のダウンロードボタンをクリックしてクライアントID等の情報が入ったJSONを取得する。
JSONの中身は以下のようになっている。

```json
{
    "installed": {
        "client_id": "XXXXXXXXXXXXXXXXXXXXXX.apps.googleusercontent.com",
        "project_id": "dependable-aloe-XXXXX",
        "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_secret": "XXXXXXXXXXXXXXXXXXXX",
        "redirect_uris": [
            "urn:ietf:wg:oauth:2.0:oob",
            "http://localhost"
        ]
    }
}
```
**(4) 認可画面を開いて認証**

(3)で取得したJSONを元に以下URLのクエリパラメータを編集してアクセスする。

```
https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id={client_id}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=https://www.googleapis.com/auth/spreadsheets%20https://www.googleapis.com/auth/drive&access_type=offline
```
※scopeには使用したいAPIを指定する。以下のページからコピペ要
https://developers.google.com/identity/protocols/googlescopes

※Google Sheet API を使用したい場合は、scopeにGoogle Drive APIも一緒に指定しないとAccessToken取得時にinvalid_grantエラーになるので注意

上記URLにアクセスして、scopeに指定したAPIを認証すると以下画面に「認証コード(Authorization Code)」が表示される

![AuthorizationCode作成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319263/3a0ffbc3-0aed-ea0a-4f09-7125007e1b50.png)

**(5) Access Token 取得**

(3)(4)で取得した情報を元に以下curlコマンドを組み立てて実行
※{authorization_code}は(4)の認証コード、{client_id},{clietn_secret}は(3)のJSONの値に置き換え

```sh
curl --data "code={authorization_code}" --data "client_id={client_id}" --data "client_secret={clietn_secret}" --data "redirect_uri=urn:ietf:wg:oauth:2.0:oob" --data "grant_type=authorization_code" --data "access_type=offline" https://www.googleapis.com/oauth2/v4/token
```


**(おまけ) Refresh Token で Access Token を再取得**

(5)で取得した Access Tokenには有効期限があるため、一定時間経つと使用できなくなる。
以下curlコマンドでAccess Tokenの再取得が可能

```sh
curl --data "refresh_token={refresh_token}" --data "client_id={client_id}" --data "client_secret={client_secret}" --data "grant_type=refresh_token" https://www.googleapis.com/oauth2/v4/token
```


## 3. Node.jsによるRest API 連続実行

### 3.1. 下準備

**(1) Node.js環境構築**

```console
$ npm init -y
$ npm install request -- save
$ npm install googleapis --save
$ npm install fs --save
```

**(2) JavaScriptソースファイル(index.js等)を作成**

**(3) cledentials.json と token.json を用意**

cledentials.jsonは2章(3)で取得したJSON

```cledentials.json
{
    "installed": {
        "client_id": "XXXXXXXXXXXXXXXXXXXXXX.apps.googleusercontent.com",
        "project_id": "dependable-aloe-XXXXX",
        "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_secret": "XXXXXXXXXXXXXXXXXXXX",
        "redirect_uris": [
            "urn:ietf:wg:oauth:2.0:oob",
            "http://localhost"
        ]
    }
}
```

token.jsonは2章(5)のレスポンス

```token.json
{ 
"access_token":"XXXXXXXXXXXXXXXXX",
"expires_in":3600,
"refresh_token":"XXXXXXXXXXXXXXXXX",
"scope":"https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive",
"token_type":"Bearer"
}
```

**(4) 以下公式のサンプルコードを改造して作成するので理解する**

https://developers.google.com/sheets/api/quickstart/nodejs

※上記サンプルコードで、2章(4),(5)は半自動で実施可能。実行にはcledentials.jsonが必須。

### 3.2. 実際に作ったプログラム
（題材はなんでも良かったが）ポケモンの種族値から実際のステータス値を算出/出力するプログラムを作ってみた（真面目にやると複数の変動値を考慮する必要があるのでその辺は固定値にした）。

１回の実行で、ソース内で指定しているGoogleスプレッドシートに対して、REST API(Google Sheet API)を連続で実行して以下を行う。

(1) BaseStatsシートのB3～G5の範囲のデータ(種族値)取得
ソース上の対応メソッド：getSpreadSheetData()
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319263/4edb5d57-ecfd-ec25-8ba2-6a5c1f6cbc80.png)

(2) StatisticsシートのB3～G5の範囲にデータ(計算結果の実数値)書き込み
ソース上の対応メソッド：writeSpreadSheet()
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319263/ae508b4f-55fc-8cfe-efb8-87b0d58dfe4b.png)
　↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319263/c3b606c7-cfde-428b-c18d-7cc681637716.png)

(3) REST API実行時にAccess Tokenの有効期限切れのエラーとなった場合はAccess Tokenを再取得して１回だけ再実行する。
ソース上の対応メソッド：retryCall()
<br>

**・ソースコード**
※authorize(), getNewToken()は参考サイト（ https://developers.google.com/sheets/api/quickstart/nodejs ）とほぼ同じ
※google.sheetsを使用すればわざわざURL指定をする必要ないが、今回の趣旨に反するためあえて使用していない。
※readFile()やrequest.get(), request.post()等は非同期実行なので注意する

```index.js
const request = require('request');
const { google } = require('googleapis');
const fs = require('fs');

// constant
const CLEDENT_PATH = 'credentials.json';
const TOKEN_PATH = 'token.json';
const READ_SHEET_URL = 'https://sheets.googleapis.com/v4/spreadsheets/1zi8P6wQujXtLvPriViF9Z82-6VlDrBUvUdb_dlgSwnA/values/BaseStats!B3:G5';
const WRITE_SHEET_URL = 'https://sheets.googleapis.com/v4/spreadsheets/1zi8P6wQujXtLvPriViF9Z82-6VlDrBUvUdb_dlgSwnA/values/Statistics!B3:G5';
const REFRESH_URL = 'https://www.googleapis.com/oauth2/v4/token';

// get Access Token
fs.readFile(CLEDENT_PATH, (err, content) => {
    if (err) return console.log('Error loading client secret file:', err);
    // Authorize a client with credentials, then call the Google Sheets API.
    authorize(JSON.parse(content), getSpreadSheetData);
});

function authorize(credentials, callback) {
    const { client_secret, client_id, redirect_uris } = credentials.installed;
    const oAuth2Client = new google.auth.OAuth2(
        client_id, client_secret, redirect_uris[0]);

    // Check if we have previously stored a token.
    fs.readFile(TOKEN_PATH, (err, token) => {
        if (err) return getNewToken(oAuth2Client, callback);
        oAuth2Client.setCredentials(JSON.parse(token));

        callback(oAuth2Client, false);
    });
}

function getNewToken(oAuth2Client, callback) {
    const authUrl = oAuth2Client.generateAuthUrl({
        access_type: 'offline',
        scope: SCOPES,
    });
    console.log('Authorize this app by visiting this url:', authUrl);
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
    });
    rl.question('Enter the code from that page here: ', (code) => {
        rl.close();
        oAuth2Client.getToken(code, (err, token) => {
            if (err) return console.error('Error while trying to retrieve access token', err);
            oAuth2Client.setCredentials(token);
            // Store the token to disk for later program executions
            fs.writeFile(TOKEN_PATH, JSON.stringify(token), (err) => {
                if (err) return console.error(err);
                console.log('Token stored to', TOKEN_PATH);
            });
            callback(oAuth2Client, false);
        });
    });
}

// read Spread Sheet
function getSpreadSheetData(outh2Client, isRetry) {
    request.get({
        uri: READ_SHEET_URL,
        headers: { 'Content-type': 'application/json' },
        qs: {
            access_token: outh2Client.credentials.access_token,
        },
        json: true
    }, function (err, req, data) {
        if (data.error) {
            retryCall(data.error, outh2Client, getSpreadSheetData, isRetry)
        } else {
            console.log("--- Result Sheet Reading ---\n" + JSON.stringify(data));
            let writeValues = generateStats(data.values);
            writeSpreadSheet(outh2Client, writeValues, false);
        }
    });
}

// write Spread Sheet
function writeSpreadSheet(outh2Client, writeValues, isRetry) {
    request.put({
        uri: WRITE_SHEET_URL,
        headers: { 'Content-type': 'application/json' },
        qs: {
            access_token: outh2Client.credentials.access_token,
            valueInputOption: "USER_ENTERED"
        },
        json: {
            "range": "Statistics!B3:G5",
            "majorDimension": "ROWS",
            "values": writeValues
        }
    }, function (err, req, data) {
        if (data.error) {
            retryCall(data.error, outh2Client, writeSpreadSheet, isRetry)
        } else {
            console.log("--- Result Sheet Writing ---\n" + JSON.stringify(data));
            console.log("writeData: " + JSON.stringify(writeValues));
        }
    });
}

// retry rest api execute
function retryCall(error, outh2Client, callback, isRetry) {
    if (!isRetry && error.code === 401 && error.status === "UNAUTHENTICATED") {
        refreshToken(outh2Client, callback);
    } else {
        console.log("[api error]");
        console.log(data);
        return;
    }
}

// refresh Access Token
function refreshToken(outh2Client, callback) {
    request.post(REFRESH_URL, {
        form: {
            grant_type: 'refresh_token',
            refresh_token: outh2Client.credentials.refresh_token,
            client_id: outh2Client._clientId,
            client_secret: outh2Client._clientSecret
        }
    }, function (err, res, data) {
        data = JSON.parse(data);
        if (data.error) {
            console.log("[error refresh failure]");
            console.log(data);
        } else {
            outh2Client.credentials.access_token = data.access_token;
            fs.writeFile(TOKEN_PATH, JSON.stringify(outh2Client.credentials), (err) => {
                if (err) return console.error(err);
                console.log('Token stored to', TOKEN_PATH);
            });
            callback(outh2Client, true);
        }
    });
}


// calculate pokemon Statistics
function generateStats(baseStatsList) {
    let statsList = [];
    for (baseStats of baseStatsList) {
        let stats = [];
        stats.push(calculateHP(baseStats[0]));
        for (let i = 1; i < baseStats.length; i++) {
            stats.push(calculateOther(baseStats[i]));
        }
        statsList.push(stats);
    }
    return statsList;
}

const level = 50;
const individualValue = 31;
const effortValue = 0;
const natureCorrectionRate = 1;

function calculateHP(baseStat) {
    let stat = parseInt((baseStat * 2 + individualValue + effortValue / 4) * (level / 100)) + 10 + level;
    return stat;
}

function calculateOther(baseStat) {
    let stat = parseInt((parseInt((baseStat * 2 + individualValue + effortValue / 4) * (level / 100)) + 5) * natureCorrectionRate);
    return stat;
}
```

**・実行後のターミナル出力結果（例）**

```console
PS C:\developments\vsCode\restNodeJs> node index.js
--- Result Sheet Reading ---
{"range":"BaseStats!B3:G5","majorDimension":"ROWS","values":[["80","82","83","100","100","80"],["78","84","78","109","85","100"],["79","83","100","85","105","78"]]}
--- Result Sheet Writing ---
{"spreadsheetId":"1zi8P6wQujXtLvPriViF9Z82-6VlDrBUvUdb_dlgSwnA","updatedRange":"Statistics!B3:G5","updatedRows":3,"updatedColumns":6,"updatedCells":18}
writeData: [[155,102,103,120,120,100],[153,104,98,129,105,120],[154,103,120,105,125,98]]
```

## 参考サイト
・Google APIのAccess Tokenをお手軽に取得する
https://qiita.com/shin1ogawa/items/49a076f62e5f17f18fe5

・Google APIを使用するためにGoogle OAuth認証をしようよ
https://himakan.net/websites/how_to_google_oauth

・【Google API入門（1）】Google OAuthでAccess Tokenを取得してみる
https://poppingcarp.com/google-api_get_access_token/

・Node.jsでGoogle APIをOAuth2.0認証してAPIを使う方法
https://photo-tea.com/p/17/nodejs-google-api-oauth/

・Node.jsからWebAPIを叩く
https://qiita.com/yuta0801/items/ff7f314f45c4f8dc8a48

・node.js – Google OAuthがリフレッシュトークンを使用して新しいアクセストークンを取得する
https://codeday.me/jp/qa/20190625/1095976.html

・Google Sheet API v4 ガイド
https://developers.google.com/sheets/api/quickstart/nodejs

・Google Sheet API v4 サンプル
https://developers.google.com/sheets/api/samples/