Electron での OpenStreetMap の OAuth トークンの取得と API へのアクセスの実装

以前、[OpenStreetMapで クライアントサイドの JavaScript で API に OAuth で接続する方法を投稿](http://qiita.com/tom_konda/items/eb36d096e52089aef8a5)しましたが、その際、Electron では上記の記事の方法で接続できないと説明しました。
今回は、Electron で OpenStreetMap の API に OAuth で接続する方法を紹介します。

## 使用するライブラリ

今回は、OAuth の接続を行うライブラリには [oauth](https://www.npmjs.com/package/oauth) を使用します。
アクセストークンをアプリ終了後にも保持するために、[node-localstorage](https://www.npmjs.com/package/node-localstorage) のような、Node.js で利用可能な設定をストレージできるライブラリも使用します。

## サンプルソースコード

[Github](https://github.com/tom-konda/OSMElectronOAuthDemo)

## 実装例

以下の部分で示す API へのアクセス部分の実装は、main-process 側で処理を行うようにし、API アクセス時のレスポンスなどは ipc を使用して渡しています。renderer 側での処理は ipc を除いて、クライアントサイド JavaScript の書き方に合わせたかったためです。renderer 側の実装も確認したい場合は、サンプルソースコードに置いてあるのでそちらでご確認ください。

### アクセストークンの取得
OAuth で使用するライブラリの読み込みと OAuth の初期化は以下のようになります。
以前にアクセスした時のアクセストークンがあれば、設定をストレージから取得します。

```json:/config/settings.json
{
  "oauth_consumer_key": "APP_CONSUMER_KEY",
  "oauth_secret": "APP_SECRET_KEY",
  "server" : "http://api06.dev.openstreetmap.org"
}
```

```js:main.js
const authConfig = require('./config/settings.json');
const OAuth = require('oauth');
const JSONStorage = require('node-localstorage');

const OSMOAuth = new OAuth.OAuth(
  `${authConfig.server}/oauth/request_token`,
  `${authConfig.server}/oauth/access_token`,
  authConfig.oauth_consumer_key,
  authConfig.oauth_secret,
  '1.0',
  'http://localhost/',
  'HMAC-SHA1'
);

let oauthState = {
    oauthToken: '',
    oauthSecret: '',
    accessToken: '',
    accessSecret: '',
};

try {
    const storedOAuthState = nodeStorage.getItem('oauthState');
    if (storedOAuthState === null) {
        nodeStorage.setItem('oauthState', oauthState);
    }
    else {
        oauthState = storedOAuthState;
    }
}
catch (err) {
    console.error(err);
    nodeStorage.setItem('oauthState', oauthState);
}
```

初期化の際の注意点ですが、[OpenStreetMap wiki の OAuth のページ](https://wiki.openstreetmap.org/wiki/OAuth)では、

> OpenStreetMap supports OAuth 1.0 and 1.0a but 1.0a should be used for any new application, as 1.0 is for certain legacy clients only.

と書いてありますが、1.0A でアクセスするとなぜかエラーが返ってくるので、1.0 でアクセスします。

OAuth の初期化が終わったら、下記の順で処理を行っていきます。

1. リクエストトークンの取得
2. アプリケーションで使用する権限の確認と決定
3. アクセストークンの取得

### リクエストトークンの取得

リクエストトークンは oauth ライブラリのメソッドを呼び出して取得ができます。
リクエストトークンの取得メソッドの呼び出し方は以下の通りです。

```js
OSMOAuth.getOAuthAccessToken((error, token, secret) => {
  // リクエストトークン取得成否処理
  if (error === null) {
    // 成功
  }
  else {
    // 失敗
  }
});
```

oauth ライブラリではトークンの取得や API の呼び出しの際、成功していればコールバック関数の第1引数の error は null になり、そうでなければ何かしらの値が入ります。成否の処理を行う際はこの引数の値をもとに行うのが良いと思います。

### アプリケーションで使用する権限の確認と決定

OpenStreetMap でアプリケーションで使用する権限の確認と決定をする際は、renderer のブラウザを使用した方が楽に処理できるため、`new BrowserWindow()` を使用して、新しいウィンドウを作成します。その際、[セキュリティ上の問題を避ける](http://utf-8.jp/public/2016/0629/electron.pdf)ため、`nodeIntegration : false` にしてウィンドウを作成します。
アプリケーションで使用する権限の確認画面で権限を設定してフォームを送信すると、OpenStreetMap に登録したアプリケーションに設定したコールバック URL に、アクセストークンの取得で必要な`oauth_verifier` パラメータを含んで返ってくるので、その値を使用して `OSMOAuth.getOAuthAccessToken()` を実行します。

### アクセストークンの取得

アクセストークンの取得はリクエストトークン同様に oauth ライブラリのメソッドを呼び出して取得ができます。
アクセストークンの取得メソッドの呼び出しの際は、`oauth_verifier` パラメータの値を第3引数に設定します。

```js
OSMOAuth.getOAuthAccessToken(oauthState.oauthToken, oauthState.oauthSecret, verifier_value,
  (error, token, secret) => {
    // アクセストークン取得成否処理
    if (error === null) {
      // 成功
    }
    else {
      // 失敗
    }
  }
);
```

アクセストークンが取得成功したら、トークンをストレージに保存します。
初期化以後の処理の流れをコードにすると以下のように書く事が出来ます。

```js:main.js
// renderer 側から ipc で呼び出す
ipcMain.on('requestOAuthWindow', (event) => {
  let OAuthWindow = new BrowserWindow({
    width: 360,
    height: 600,
    resizable: false,
    minimizable: false,
    webPreferences: {
      javascript: false,
      nodeIntegration: false,
      defaultEncoding: 'utf8',
    }
  });

  //（省略）

  OSMOAuth.getOAuthRequestToken(function (error, token, secret) {
    if (error === null) {
      oauthState.oauthToken = token;
      oauthState.oauthSecret = secret;
      OAuthWindow.loadURL(`${authConfig.server}/oauth/authorize?oauth_token=${token}`);
    }
    else {
      dialog.showErrorBox('OAuth Request Token の取得に失敗しました。', `HTTP ステータスコード ${error.statusCode} が返りました`);
      console.error(error.data);
    }
  });

  OAuthWindow.webContents.on('will-navigate', function (windowEvent, url) {
    let matched;
    if (matched = url.match(/\?oauth_token=([^&]*)&oauth_verifier=([^&]*)/)) {
      OSMOAuth.getOAuthAccessToken(oauthState.oauthToken, oauthState.oauthSecret, matched[2], function (error, token, secret) {
        if (error === null) {
          oauthState.accessToken = token;
          oauthState.accessSecret = secret;
          nodeStorage.setItem('oauthState', oauthState);
          event.sender.send('oauthSuccess');
          OAuthWindow.close();
        }
        else {
          dialog.showErrorBox('OAuth Access Token の取得に失敗しました。', 'HTTP ステータスコード ${error.statusCode} が返りました');
          console.error(error);
        }
      });
    }
  });
});
```

### APIへのアクセスの実装例

APIへのアクセスを行うには、oauth ライブラリにある各 HTTP リクエストメソッドと同名のメソッドを呼び出します。呼び出せるリクエストメソッドは `GET`、`POST`、`PUT`、`DELETE` の4つになります。
それぞれのメソッドの呼び出し方は下記のような形になります。

- DELETE、GET の場合

DELETE と GET の場合は、データを設定する必要が無いため（ごく一部の API で URLのパラメータに設定が必要）、取得したアクセストークンと URL を使えば、API にアクセスする事が出来ます。

```js
OSMOAuth.delete('APIのURL', 'アクセストークン', 'アクセストークンシークレット', (error, response, result) => {
  // コールバック関数、APIの成否処理を書く
  if (error === null) {
    // 成功
  }
  else {
    // 失敗
  }
});
```

- PUT、POST の場合

PUT と POST の場合は、送信するデータを設定する必要があります。データの形式は、[OSM XML](https://wiki.openstreetmap.org/wiki/OSM_XML) だったりフォーム形式だったりと使用するAPI ごとに異なるため必ず [API のドキュメント](https://wiki.openstreetmap.org/wiki/API_v0.6) を確認しましょう。

```js
OSMOAuth.put('APIのURL', 'アクセストークン', 'アクセストークンシークレット', 'POST or PUT するデータ', 'データの形式',
  (error, response, result) => {
  // コールバック関数、APIの成否処理を書く
  if (error === null) {
    // 成功
  }
  else {
    // 失敗
  }
  }
);
```

どちらの場合でも、成功した際に第2引数にレスポンス が返ってきますが、XML だった場合はmain-process 側で処理するよりも renderer 側で処理した方が、ライブラリを導入せずともブラウザがネイティブで持っている XML パーサを使用できるため、特別な事情が無い限り XML のレスポンスは ipc で renderer 側に渡した方が良いかと思います。
APIへのアクセスを行う際に、アクセストークンがユーザによって revoke されている可能性があるので、その際はアクセストークンを消去します。

ここまでの内容を GET と PUT で

```js:main.js
// renderer 側から ipc で呼び出す
ipcMain.on('requestUserData',
  (event) => {
    OSMOAuth.get(`${authConfig.server}/api/0.6/user/details`, oauthState.accessToken, oauthState.accessSecret,
      (error, XMLResponse, result) => {
        if (error === null) {
          event.sender.send('requestUserDataSuccess', XMLResponse);
        }
        else {
          dialog.showErrorBox('ユーザデータの取得に失敗しました。', `HTTP ステータスコード ${error.statusCode} が返りました。
      アクセストークンが失効した可能性があります。
      再ログインしてください。`);
          event.sender.send('oauthLogout');
          oauthState.oauthToken =
            oauthState.oauthSecret =
            oauthState.accessToken =
            oauthState.accessSecret = '';
          nodeStorage.setItem('oauthState', oauthState);
          console.error(error);
        }
      }
    );
  }
);

ipcMain.on('requestTestChangeset', (event) => {
    const createChangeset = () => {
        const changesetData = `
<osm>
  <changeset>
    <tag k="created_by" v="OSM Electron OAuth Demo"/>
    <tag k="comment" v="Just as test changeset."/>
    <tag k="source" v="survey" />
  </changeset>
</osm>
    `;
        return new Promise((resolve, reject) => {
            OSMOAuth.put(`${authConfig.server}/api/0.6/changeset/create`, oauthState.accessToken, oauthState.accessSecret, changesetData, 'text/xml', (error, response, result) => {
                if (error === null) {
                    resolve(response);
                }
                else {
                    reject(error);
                    console.error(error);
                }
            });
        });
    };
    const closeChangeset = (changesetId) => {
        return new Promise((resolve, reject) => {
            OSMOAuth.put(`${authConfig.server}/api/0.6/changeset/${changesetId}/close`, oauthState.accessToken, oauthState.accessSecret, `<osm />`, 'text/xml', (error, XMLResponse, result) => {
                if (error === null) {
                    resolve(changesetId);
                }
                else {
                    reject(error);
                }
            });
        });
    };
    createChangeset().then(closeChangeset).then((changesetId) => {
        dialog.showMessageBox({
            title: `変更セット ${changesetId} の作成に成功しました。`,
            message: `変更セットURL: ${authConfig.server}/changeset/${changesetId}`,
            buttons: [
                '変更セットを確認する',
                '閉じる'
            ],
        }, (clickedButton) => {
            if (clickedButton === 0) {
                shell.openExternal(`${authConfig.server}/changeset/${changesetId}`);
            }
        });
    }).catch((error) => {
        dialog.showErrorBox('変更セットの作成に失敗しました。', `
HTTP ステータスコード ${error.statusCode} が返りました。
アクセストークンが失効した可能性があります。
再ログインしてください。
          `);
        event.sender.send('oauthLogout');
        oauthState.oauthToken =
        oauthState.oauthSecret =
        oauthState.accessToken =
        oauthState.accessSecret = '';
        nodeStorage.setItem('oauthState', oauthState);
    });
}
```

## まとめ

今回の内容をまとめると以下のようになります。

- Electron を使用する際に OAuth の接続方法がクライアントサイドと異なることを示した
  - クライアントサイドと異なり、Electron 使用時はアクセストークンの保存処理が必要
- ウェブサイトを表示する際は、`nodeIntegration : false` を設定する
- API で PUT か POST でアクセスする必要があるものはデータ形式に注意
  - API のドキュメントを確認する