Oracle Object Storage API を操作する Node.js スクリプトを日本語圏向けに微修正

唐突に Oracle Object Storage の話をする。

目次

オブジェクトストレージとは

Object Storage とは、ファイルを「オブジェクト」という概念で操作できるようにしたアーキテクチャ。

スラッシュ / でディレクトリ風の階層も表現できたりするので、REST API の URL として直接表現しやすかったりする。「ファイル = オブジェクト」と思って良い。

クラウドサービスで見かけることが多く、「Amazon Simple Storage Service (AWS S3)」や「Google Cloud Storage (GCS)」が有名。

Oracle Object Storage Service もこうしたクラウドサービスの中の一つで、Oracle Cloud Infrastracture (IaaS) の中の1機能として使える。

Oracle Object Storage API

で、この Oracle Object Storage だが、REST API 経由でファイルを取得したり保存したりできる。

REST API でやり取りするためにはリクエストヘッダに署名を設定したりする必要があるのだが、コチラは各種言語でのサンプルコードが以下に載っている。

Node.js 版のサンプルコードは以下のハッシュ。

コレで通信時の基礎を作っておき、あとは Object Storage API をコールするように URL パスやリクエストボディなんかを設定して使えば良い、というモノだ。

今回の趣旨は、このコードの整理と、日本語圏で問題になるバグを見つけたので、それを修正して使いやすくする、というところ。

サンプルコードのバグ

Node.js 版のサンプルコードのみであれば、以下のいずれかの URL で確認できる。

Version 1.0.1 となっていて、普通に使っているとほとんど問題なかったのだが、日本語を含むファイルを PUT した時に、ファイルの末尾数文字が欠落するというバグがあった。

原因は、51行目の以下の部分。

request.setHeader("Content-Length", options.body.length);

察しの良い方はもう分かるだろう。少し前にココ単体で記事にしたのだが、String.length で文字列の長さを求めて Content-Length ヘッダに設定しているのが問題。日本語のようなマルチバイトも「1文字」とカウントしてしまい、「2バイト」ないしは「3バイト」とカウントしていないせいで、全角の文字数分だけのバイト数、ファイルの末尾が千切れる事態になった。

ということで、この行を次のように直すと、日本語を含むファイルを送信しても大丈夫になる。

request.setHeader('Content-Length', Buffer.byteLength(options.body, 'utf8'));

ココだけ直して、それ以外はサンプルコードを見れば使えます、という人は、この先は見なくても良い。

サンプルコードを Promise 化して使いやすくしてみた

このサンプルコードはコールバック形式で書きづらいので、Promise として呼べるように加工してみた。

元のサンプルコードからそうだが、http-signaturejssha というライブラリが必要なので、package.json を用意し事前に npm install しておく。

$ npm init -y
$ npm install -S http-signature jssha

以下にコードを置いた settings.js を各自の内容に変更し、examples.js を加工して oracle-cloud-rest-api-wrapper.js をコールすれば良い。

/** API コールに必須な登録情報 */
const settings = {
  /** テナンシー ID */
  tenancyId: 'ocid1.tenancy.oc1..xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  /** API キーを登録したユーザ ID */
  authUserId: 'ocid1.user.oc1..xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  /** API キーのフィンガープリント */
  keyFingerprint: 'xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx',
  /** API 秘密鍵の内容 (ベタ書きが嫌なら fs.readFileSync() で読み込むなどしてください) */
  privateKey: `-----BEGIN RSA PRIVATE KEY-----
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
-----END RSA PRIVATE KEY-----`
};

module.exports = settings;
/** 使い方サンプル */

const settings = require('./settings');
const OracleCloudRestApiWrapper = require('./oracle-cloud-rest-api-wrapper');
const oracleCloudRestApiWrapper = new OracleCloudRestApiWrapper(settings);

/** 各種定数 */
const constants = {
  /** REST API のエンドポイント : 自分のリージョンに合わせて設定する */
  apiEndpoint: {
    /** Identity : ユーザ情報 */
    identity: 'identity.us-ashburn-1.oraclecloud.com',
    /** Object Storage */
    objectStorage: 'objectstorage.us-ashburn-1.oraclecloud.com'
  },
  /** テナンシー名 */
  tenancyName: 'myTenancy',
  /** バケット名 */
  bucketName: 'muBucket'
}

/**
 * オブジェクト一覧を取得する
 * 
 * @return {Promise} 'objects' がトップレベルの連想配列
 */
function listObjects() {
  const options = {
    host: constants.apiEndpoint.objectStorage,
    path: `/n/${encodeURIComponent(constants.tenancyName)}/b/${encodeURIComponent(constants.bucketName)}/o`,
    method: 'GET'
  };
  return oracleCloudRestApiWrapper.execRequest(options)
    .then((rawResults) => {
      return JSON.parse(rawResults);
    });
}

/**
 * オブジェクトを取得する
 * 
 * @param {string} objectName オブジェクト名
 * @return {Promise} オブジェクトの中身
 */
function getObject(objectName) {
  const options = {
    host: constants.apiEndpoint.objectStorage,
    path: `/n/${encodeURIComponent(constants.tenancyName)}/b/${encodeURIComponent(constants.bucketName)}/o/${encodeURIComponent(objectName)}`,
    method: 'GET'
  };
  return oracleCloudRestApiWrapper.execRequest(options);
}

/**
 * オブジェクトを保存する (既に存在する場合は上書き保存)
 * 
 * @param {string} objectName オブジェクト名
 * @param {string} body オブジェクトの中身
 * @return {Promise} 結果
 */
function putObject(objectName, body) {
  const options = {
    host: constants.apiEndpoint.objectStorage,
    path: `/n/${encodeURIComponent(constants.tenancyName)}/b/${encodeURIComponent(constants.bucketName)}/o/${encodeURIComponent(objectName)}`,
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json'  // ファイルの中身に関わらずこの指定で良い
    }
  };
  return oracleCloudRestApiWrapper.execRequest(options, body);
}

/**
 * ユーザ情報を取得する
 * 
 * @param {string} userId ユーザ ID
 * @return {Promise} ユーザ情報
 */
function getUser(userId) {
  const options = {
    host: constants.apiEndpoint.identity,
    path: `/20160918/users/${encodeURIComponent(userId)}`,
    method: 'GET'
  };
  return oracleCloudRestApiWrapper.execRequest(options);
}
/** Oracle Cloud REST API をコールする処理のラッパークラス */

const https = require('https');

const httpSignature = require('http-signature');
const jssha = require('jssha');

/** Oracle Cloud REST API Wrapper */
class OracleCloudRestApiWrapper {
  /**
   * コンストラクタ
   * 
   * @param {*} settings 各種登録情報
   */
  constructor(settings) {
    this.https = https;
    
    this.httpSignature = httpSignature;
    this.jssha = jssha;
    
    // 登録情報を取り出す
    this.settings = settings;
    this.tenancyId = this.settings.tenancyId;
    this.authUserId = this.settings.authUserId;
    this.keyFingerprint = this.settings.keyFingerprint;
    this.privateKey = this.settings.privateKey;
  }
  
  /**
   * リクエストオブジェクトに署名認証情報を付加する
   * 
   * @param {*} request リクエストオブジェクト。直接書き換える
   * @param {*} options キー情報などのオプション
   */
  sign(request, options) {
    const apiKeyId = `${options.tenancyId}/${options.userId}/${options.keyFingerprint}`;
    const headersToSign = ['host', 'date', '(request-target)'];
    // POST か PUT の場合はヘッダにハッシュを追加する
    const methodsThatRequireExtraHeaders = ['POST', 'PUT'];
    if(methodsThatRequireExtraHeaders.includes(request.method.toUpperCase())) {
      options.body = options.body || '';
      const shaObj = new this.jssha('SHA-256', 'TEXT');
      shaObj.update(options.body);
      request.setHeader('Content-Length', Buffer.byteLength(options.body, 'utf8'));  // 全角文字も考慮してバイト数を計算するよう修正
      request.setHeader('x-content-sha256', shaObj.getHash('B64'));  // Object を PUT する時は不要なようだが設定しても問題なし
      headersToSign.push('content-type', 'content-length', 'x-content-sha256');
    }
    // リクエストヘッダに署名情報を追加する
    this.httpSignature.sign(request, {
      key: options.privateKey,
      keyId: apiKeyId,
      headers: headersToSign
    });
    // ヘッダをさらに調整する
    const newAuthHeaderValue = request.getHeader('Authorization').replace('Signature ', 'Signature version="1",');
    request.setHeader('Authorization', newAuthHeaderValue);
  }
  
  /**
   * API コールする
   * 
   * @param {*} options https.request() の第1引数に渡すオプション
   * @param {*} requestBody POST・PUT の場合に付与したいリクエストボディ
   * @return {Promise} レスポンスを Resolve する
   */
  execRequest(options, requestBody) {
    return new Promise((resolve, reject) => {
      // リクエスト情報を生成する
      const request = this.https.request(options, (response) => {
        let responseBody = '';
        response
          .on('data', (chunk) => {
            responseBody += chunk;
          })
          .on('end', () => {
            // レスポンスを Resolve する
            resolve(responseBody);
          });
      })
        .on('timeout', () => {
          // タイムアウト時は通信を切断する
          request.abort();
          reject('Request timed out');
        })
        .on('error', (error) => {
          reject(error);
        });
      // 通信タイムアウトを適当に設定する
      request.setTimeout(15000);
      // リクエストオブジェクトに署名情報を追加する
      const requestOptions = {
        privateKey: this.privateKey,
        keyFingerprint: this.keyFingerprint,
        tenancyId: this.tenancyId,
        userId: this.authUserId
      };
      // POST・PUT の場合など、リクエストボディが指定されていれば追加する
      if(requestBody) {
        requestOptions.body = requestBody;
      }
      this.sign(request, requestOptions);
      // PUT Object の場合は end() に送りたいファイルの内容を指定すればファイルの中に書き込まれる
      if(requestBody) {
        request.end(requestBody);
      }
      else {
        request.end();
      }
    });
  }
}

module.exports = OracleCloudRestApiWrapper;

oracle-cloud-rest-api-wrapper.js はイジらず、settings.js の設定情報を変更し、examples.js のように利用すれば OK、という流れ。

主に Object Storage のコールのために作ったが、呼び方を変えれば getUser() のようにユーザ情報なんかも拾えたりする。どんな API があるのかは以下を参照。

以上

さすがは米国企業、body.length でおっけーしょ、というノリ。マルチバイトが当たり前な国の人のことも考えたってください…。