2015年2月7日土曜日

GoogleDriveを使うChromeアプリのサンプル

GoogleDrive上のファイルをアップロード/ダウンロードするときは Google APIs Client Library (gapi) を使うのが最も簡単な方法ですが、Chrome Packaged Apps(Chrome Apps)の場合だとセキュリティポリシーの関係で外部URLにある gapi を読み込むことができません。Chrome Apps から gapi を無理くり使うためのライブラリもあるみたいですが、こちらはまだ完全ではないようです。

ですから今回は、 gapi を使わないで Chrome Apps から GoogleDrive にアクセスする方法をご紹介したいと思います。
以下はそのサンプルコードです。



プロジェクト構成

manifest.json
background.js
main.html
upload.js
app.js


manifest.json
{
  "name": "Google Drive Example App",
  "version": "0.0.1",
  "manifest_version": 2,
  "minimum_chrome_version": "29",
  "oauth2": {
    "client_id": "*******.apps.googleusercontent.com", /* generate on Google Developers Console */
    "scopes": [
      "https://www.googleapis.com/auth/drive"
    ]
  },
  "icons": {
    "128": "icon_128.png"
  },
  "key": "*********", /* your app's key */
  "app": {
    "background": {
      "scripts": ["background.js"]
    }
  },
  "permissions": [
    "identity",
    "https://ssl.gstatic.com/",
    "https://www.googleapis.com/",
    "https://docs.google.com/",
    "https://accounts.google.com/"
  ]
}

manifest.json の "client_id" については Google Developers Console の "APIと認証" > "認証情報" の "新しいクライアントIDを作成" で予め取得しておく必要があります。
そして "APIと認証" > "API" の "有効なAPI" では "Drive API" を追加しておきます。

manifest.json の "key" については Chromeブラウザの「拡張機能」画面から「拡張機能のパッケージ化...」してcrx化したものをインストールし、インストール先にある manifest.json の中の "key" をコピーしてくればOKです。


background.js
chrome.app.runtime.onLaunched.addListener(function() {
  chrome.app.window.create('main.html', {
    'bounds': {
      'width': 400,
      'height': 500
    }
  });
});


main.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8"></meta>
  <title>Google Drive Example App</title>
  <link href="main.css" rel="stylesheet"></link>
</head>
<body>
  <button id="btn_auth">auth</button>
  <div id="div_status">Not Authorized.</div>
  <hr>
  filename: <input type="text" id="txt_filename" value="test.txt"><br>
  content:<br>
  <textarea id="ta_content"></textarea><br>
  <button id="btn_upload">upload</button>
  <hr>
  <button id="btn_download">download</button><br>
  content: <div id="div_dl"></div>
  </body>
<script src="upload.js"></script>
<script src="app.js"></script>
</html>


upload.js (GoogleDriveアップロード用ライブラリ)
/**
 * Helper for implementing retries with backoff. Initial retry
 * delay is 1 second, increasing by 2x (+jitter) for subsequent retries
 * 
 * @constructor
 */
var RetryHandler = function() {
  this.interval = 1000; // Start at one second
  this.maxInterval = 60 * 1000; // Don't wait longer than a minute 
};

/**
 * Invoke the function after waiting 
 *
 * @param {function} fn Function to invoke
 */
RetryHandler.prototype.retry = function(fn) {
  setTimeout(fn, this.interval);
  this.interval = this.nextInterval_();
};

/**
 * Reset the counter (e.g. after successful request.)
 */
RetryHandler.prototype.reset = function() {
  this.interval = 1000;
};

/**
 * Calculate the next wait time.
 * @return {number} Next wait interval, in milliseconds
 *
 * @private
 */
RetryHandler.prototype.nextInterval_ = function() {
  var interval = this.interval * 2 + this.getRandomInt_(0, 1000);
  return Math.min(interval, this.maxInterval);
};

/**
 * Get a random int in the range of min to max. Used to add jitter to wait times.
 *
 * @param {number} min Lower bounds
 * @param {number} max Upper bounds
 * @private
 */
RetryHandler.prototype.getRandomInt_ = function(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
};


/**
 * Helper class for resumable uploads using XHR/CORS. Can upload any Blob-like item, whether
 * files or in-memory constructs.
 *
 * @example
 * var content = new Blob(["Hello world"], {"type": "text/plain"});
 * var uploader = new MediaUploader({
 *   file: content,
 *   token: accessToken,
 *   onComplete: function(data) { ... }
 *   onError: function(data) { ... }
 * });
 * uploader.upload();
 *
 * @constructor
 * @param {object} options Hash of options
 * @param {string} options.token Access token
 * @param {blob} options.file Blob-like item to upload
 * @param {string} [options.fileId] ID of file if replacing
 * @param {object} [options.params] Additional query parameters
 * @param {string} [options.contentType] Content-type, if overriding the type of the blob.
 * @param {object} [options.metadata] File metadata
 * @param {function} [options.onComplete] Callback for when upload is complete
 * @param {function} [options.onError] Callback if upload fails
 */
var MediaUploader = function(options) {
  var noop = function() {};
  this.file = options.file;
  this.contentType = options.contentType || this.file.type || 'application/octet-stream';
  this.metadata = options.metadata || {
    'title': this.file.name,
    'mimeType': this.contentType
  };
  this.token = options.token;
  this.onComplete = options.onComplete || noop;
  this.onError = options.onError || noop;
  this.offset = options.offset || 0;
  this.chunkSize = options.chunkSize || 0;
  this.retryHandler = new RetryHandler();

  this.url = options.url;
  if (!this.url) {
    var params = options.params || {};
    params.uploadType = 'resumable';
    this.url = this.buildUrl_(options.fileId, params);
  }
  // this.httpMethod = this.fileId ? 'PUT' : 'POST'; // bug???
  this.httpMethod = options.fileId ? 'PUT' : 'POST';
};

/**
 * Initiate the upload.
 */ 
MediaUploader.prototype.upload = function() {
  var self = this;
  var xhr = new XMLHttpRequest();

  xhr.open(this.httpMethod, this.url, true);
  xhr.setRequestHeader('Authorization', 'Bearer ' + this.token);
  xhr.setRequestHeader('Content-Type', 'application/json');
  xhr.setRequestHeader('X-Upload-Content-Length', this.file.size);
  xhr.setRequestHeader('X-Upload-Content-Type', this.contentType);

  xhr.onload = function(e) {
    var location = e.target.getResponseHeader('Location');
    this.url = location;
    this.sendFile_();
  }.bind(this);
  xhr.onerror = this.onUploadError_.bind(this);
  xhr.send(JSON.stringify(this.metadata));
};

/**
 * Send the actual file content.
 *
 * @private
 */ 
MediaUploader.prototype.sendFile_ = function() {
  var content = this.file;
  var end = this.file.size;
  
  if (this.offset || this.chunkSize) {
    // Only bother to slice the file if we're either resuming or uploading in chunks
    if (this.chunkSize) {
      end = Math.min(this.offset + this.chunkSize, this.file.size);
    }
    content = content.slice(this.offset, end);
  }
  
  var xhr = new XMLHttpRequest();
  xhr.open('PUT', this.url, true);
  xhr.setRequestHeader('Content-Type', this.contentType);
  xhr.setRequestHeader('Content-Range', "bytes " + this.offset + "-" + (end - 1) + "/" + this.file.size);
  xhr.setRequestHeader('X-Upload-Content-Type', this.file.type);
  xhr.onload = this.onContentUploadSuccess_.bind(this);
  xhr.onerror = this.onContentUploadError_.bind(this);
  xhr.send(content);
};

/**
 * Query for the state of the file for resumption.
 * 
 * @private
 */ 
MediaUploader.prototype.resume_ = function() {
  var xhr = new XMLHttpRequest();
  xhr.open('PUT', this.url, true);
  xhr.setRequestHeader('Content-Range', "bytes */" + this.file.size);
  xhr.setRequestHeader('X-Upload-Content-Type', this.file.type);
  xhr.onload = this.onContentUploadSuccess_.bind(this);
  xhr.onerror = this.onContentUploadError_.bind(this);
  xhr.send();
};

/**
 * Extract the last saved range if available in the request.
 *
 * @param {XMLHttpRequest} xhr Request object
 */
MediaUploader.prototype.extractRange_ = function(xhr) {
 var range = xhr.getResponseHeader('Range');
 if (range) {
   this.offset = parseInt(range.match(/\d+/g).pop(), 10) + 1;
 }
};

/**
 * Handle successful responses for uploads. Depending on the context,
 * may continue with uploading the next chunk of the file or, if complete,
 * invokes the caller's callback.
 *
 * @private
 * @param {object} e XHR event
 */
MediaUploader.prototype.onContentUploadSuccess_ = function(e) {
  if (e.target.status == 200 || e.target.status == 201) {
    this.onComplete(e.target.response);
  } else if (e.target.status == 308) {
    this.extractRange_(e.target);
    this.retryHandler.reset();
    this.sendFile_();
  }
};

/**
 * Handles errors for uploads. Either retries or aborts depending
 * on the error.
 *
 * @private
 * @param {object} e XHR event
 */
MediaUploader.prototype.onContentUploadError_ = function(e) {
  if (e.target.status && e.target.status < 500) {
    this.onError(e.target.response);
  } else {
    this.retryHandler.retry(this.resume_.bind(this));
  }
};


/**
 * Handles errors for the initial request.
 *
 * @private
 * @param {object} e XHR event
 */
MediaUploader.prototype.onUploadError_ = function(e) {
  this.onError(e.target.response); // TODO - Retries for initial upload
};

/**
* Construct a query string from a hash/object
*
* @private
* @param {object} [params] Key/value pairs for query string
* @return {string} query string
*/
MediaUploader.prototype.buildQuery_ = function(params) {
  params = params || {};
  return Object.keys(params).map(function(key) {
    return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
  }).join('&');
};

/**
* Build the drive upload URL
*
* @private
* @param {string} [id] File ID if replacing
* @param {object} [params] Query parameters
* @return {string} URL
*/
MediaUploader.prototype.buildUrl_ = function(id, params) {
  var url = 'https://www.googleapis.com/upload/drive/v2/files/';
  if (id) {
    url += id;
  }
  var query = this.buildQuery_(params);
  if (query) {
    url += '?' + query;
  }
  return url;
};

upload.jsはchrome-app-samples / gdriveにあったものをほぼ丸々コピーさせてもらいました。(※ちょっとしたバグを発見したので一部修正しています)
このライブラリがあるだけでアップロード周りの処理がかなりスッキリします。


app.js
var accessToken;

var btn_auth = document.getElementById("btn_auth");
var btn_upload = document.getElementById("btn_upload");
var btn_download = document.getElementById("btn_download");
var txt_filename = document.getElementById("txt_filename");
var ta_content = document.getElementById("ta_content");
var div_status = document.getElementById("div_status");
var div_dl = document.getElementById("div_dl");

btn_auth.onclick = function(e) {
  console.log("btn_auth click");
  auth(true, function() {
    console.log("accessToken", accessToken);
    div_status.textContent = "Authorized.";
  })
};

btn_upload.onclick = function(e) {
  console.log("btn_upload click");
  getFileOnGDrive(txt_filename.value, upload);
};

btn_download.onclick = function(e) {
  console.log("btn_download click");
  getFileOnGDrive(txt_filename.value, download);
};

function auth(interactive, opt_callback) {
  try {
    chrome.identity.getAuthToken({
      interactive: interactive
    }, function(token) {
      if (token) {
        accessToken = token;
        opt_callback && opt_callback();
      }
    });
  } catch (e) {
    console.log(e);
  }
};

function upload(file) {
  var filename;
  var mimeType = "text/plain";
  if (file) {
    filename = file.title;
  } else {
    filename = txt_filename.value;
  }

  // create contents.
  var txt = ta_content.value;
  var content = new Blob([txt], {
    "type": mimeType
  });

  var onComplete = function(response) {
    console.log("upload complete. response=", response);
    // var json = JSON.parse(response);
  };

  var onError = function(response) {
    console.log("upload error. response=", response);
  }

  //
  // upload
  //
  var upload_opts = {
    metadata: {
      title: filename,
      mimeType: mimeType
    },
    file: content,
    token: accessToken,
    onComplete: onComplete,
    onError: onError
  };
  // if the file has already existed, set the fileId to options param
  if (file) upload_opts.fileId = file.id;
  var uploader = new MediaUploader(upload_opts);
  uploader.upload();
}

function download(file) {
  var xhr = new XMLHttpRequest();
  var url = "https://www.googleapis.com/drive/v2/files";
  if (file) {
    // get url for download
    url = file.downloadUrl;
  }
  xhr.open('GET', url);
  xhr.setRequestHeader('Authorization',
    'Bearer ' + accessToken);
  xhr.onreadystatechange = function(e) {
    if (xhr.readyState == 4 && xhr.status == 200) {
      // get the file's content.
      console.log("xhr.responseText", xhr.responseText);
      div_dl.textContent = xhr.responseText;
    } else if (xhr.readyState == 4 && xhr.status != 200) {
      console.error("Error: status=", xhr.status);
    }
  };
  xhr.onerror = function(e) {
    console.error("Error: ", e);
  };
  xhr.send();
}

function getFileOnGDrive(filename, callback) {
  var xhr = new XMLHttpRequest();
  var url = "https://www.googleapis.com/drive/v2/files";
  xhr.open('GET', url);
  xhr.setRequestHeader('Authorization',
    'Bearer ' + accessToken);
  xhr.onreadystatechange = function(e) {
    if (xhr.readyState == 4 && xhr.status == 200) {
      var json = JSON.parse(xhr.responseText);
      var filename = txt_filename.value;
      var file = getFile(filename, json.items);
      callback(file);
      return;
    } else if (xhr.readyState == 4 && xhr.status != 200) {
      console.error("Error: status=", xhr.status);
    }
  };
  xhr.onerror = function(e) {
    console.error("Error: ", e);
  };
  xhr.send();

  // get the file having same filename and create url for downloading it
  function getFile(filename, items) {
    var item;
    for (var len = items.length, i = 0; i < len; i++) {
      item = items[i];
      if (item.title == filename) {
        return item;
      }
    }
    return null;
  }
}


実行してみよう

サンプルアプリを実行し、以下のステップで動かしてみましょう。

1) authボタンを押してaccessTokenを取得
2) GoogleDrive上に作成するファイル名をtxt_filenameテキストボックスに入力
3) uploadボタンを押して適当な内容でファイルをアップロード
4) downloadボタンを押して 3) でアップしたファイルをダウンロード

Note

GoogleDriveでは同名のファイルをアップロードすると、毎回別ファイルとしてDrive上に重複保存します。ファイル名は同じでもファイルIDが別物だからです。上の例では、同名ファイルの重複保存を防ぐために、uploadの前には必ずDrive上に同名のファイルが無いかチェックして(#getFileOnGDrive)、既に存在する場合はそのファイルIDを使って上書き、無ければ新規作成するようにしています。

参考にさせていただいたコード
chrome-app-samples/samples/gdrive/

追記 2015/03/08
GoogleDriveへのアップロード|ダウンロード処理をライブラリ化してみました。

0 件のコメント:

コメントを投稿