LoginSignup
16
16

More than 3 years have passed since last update.

FirestoreのコレクションをGoogleSpreadsheet+GASでいい感じに読み書きする

Posted at

はじめに

2018年のGAS AdventCalenderではGAS+Vue.jsでSpreadsheetのサイドバーを作成する記事を書かせて頂きました。
今年はその仕組の延長としてGASサイドバーからFirebase(Firestore)を使って簡単なデータベース管理をやってみたいと思います。

作成の動機

昨年から今年にかけて、社員向けのWebアプリをいくつか作る機会があり、それぞれFirebase+Nuxt.jsでSPAのPWAというベタな構成で運用してます。
作ったアプリは管理機能をあまり作りこんでおらず、もっぱらFirebaseコンソールから直にデータベースを更新したりしてたのですが、数が増えてくれるとDBを直で弄るのはそれなりに面倒。
とはいえ管理といっても、やってる事と言えばマスタテーブルの修正やら、バックアップ用にJSONを保存する程度の話なので、毎度管理画面をいちいち作るのも面倒だなぁ…。
ハァ…Firestoreをいい感じのデータグリッドで編集できないかなぁ…
コピペとか保存とかラクにできて、メンテナンス性が良くて…
……ってGoogleSpreadsheetを使えばいいんじゃん?

みたいな流れです。

今回の作例

成果物はこんな感じの挙動となります。
挙動.gif
想定としては、既に構築済のFirebaseプロジェクトがあり、それ上のFirestoreのデータを更新する感じの管理ツールとなります。

この記事自体で使用している技術はサーバー側が

  • GoogleSpreadsheet
  • Google Apps Script
  • Firebase Authentication
  • Firebase Cloud Firestore

フロント側は

  • Vue.js
  • Buefy

動作確認はchromeのみですがasync/awaitが動作するブラウザなら多分動くんじゃないんでしょうか?

なお、Firebaseのプロジェクト作成に関してはズバっと飛ばします。(そもそもGASのAdventCalenderなので…)
Qiitaにも図解付きのチュートリアルはいっぱいあるので、そちらをご参照ください。

記事化にあたり手直しした結果、色々と機能を削ったため作りが雑で申し訳ないでが、ご興味あるようならお付き合いください。

GASでFirestoreを操作する方法

さて、GASからFirebase/Firestoreを扱う方法として、QiitaではFirebaseAdminSDKを利用しているケースを多く見かけます。

参考例(2018年GAS AdventCalenderより)
【書籍管理シリーズ】GASとFireBase(Firestore)を連携させるよ!

こういったケースでは、FirebaseAdminSDKのGASライブラリを使うことで操作を実現しています。
しかし、そのためにはスクリプト上に秘密鍵を配置する必要があります。
開発者自身が自分のツール用途として認証情報を管理できる場合には特に問題は無いと思います。
ただ、スプレッドシートを管理ツールとしてチームで共有するようなシチュエーションでは、特権情報をスクリプト上に載せておくのは気持ち良いものではありません。

そこで今回の作例では、GASのサイドバー上でFirebase JavaScript SDKを実行し、個別のユーザー認証の元でFirestoreにアクセスするやり方で実装を行います。

ただ、GASのサイドバーからFirebaseのクライアントSDKを使う際に、いくつかポイントとなる部分があります。

サイドバーからFirebase Authenticationを使う際の注意点

Firebase Authenticationを使う際「承認済みドメイン」を登録する必要があります。
auth-domain.png
さて、GASのサイドバーは何処のドメインでホストされているのでしょうか?
スプレッドシート自体はdocs.google.comにてホストされていますが、ここを登録してもうまく動きません。

そこで、サイドバーを起動させ、location.hostで情報を見てみると、かなり長いサブドメイン名を持つgoogleusercontent.comにて動作しているのがわかります。
hostname.png

認証を行う前に必ず登録しておいてください。
なお、この(クッソ長い)ホスト名はスクリプト毎で固有の値らしく変動はしないようです。(体感ですので永久かどうかは不明)

いちいちホストを調べて登録したくない場合

注: ドメインをホワイトリストに登録すると、そのドメインのすべての URL、ポート、サブドメインからのリクエストが許可されます。

はい。サブドメインからでもAuthenticationは許可されます。
なので、認証済みドメインに googleusercontent.com とだけ登録すればくっそ長いURLを調べて登録してなくとも認証は行えます。

ただ、セキュリティ的にはどうなの?というと、*.googleusercontent.com からスクリプトを走らせることのできるユーザーは全世界に居ますのであまりよろしくは無いと思います。
localhostを認証可にしているのと同程度のセキュリティリスクかと思いますので、ご使用は計画的に。
(とはいえ、データを不正操作されるか否かは結局セキュリティルール次第ですが…)
ユーザーが広範囲に上る場合、ホワイトドメインはちゃんと登録しておきましょう。

Firestoreセキュリティルール

実際のFirestoreにアクセスを行う際、各種の書き込み権限がセキュリティルールとして設定されている必要があります。
今回の例では、各自のユーザーアカウントの権限内でデータにアクセスを行うため、データベースの設計によっては、更新等のルールに引っかかる可能性が高いです。

そういう場合、例えば管理用のメルアドでログインした場合にのみ、全権行使できるようセキュリティルールを追加してみるのもアリかと思います。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /どっかのコレクション/{uid} {
      allow read: if request.auth != null && request.auth.uid == uid;
      // アプリで使ってるルール例
    }

+    match /{document=**} {
+      allow read, write: if isValidUser();
+    }
+    function isValidUser() {
+      return request.auth.uid != null 
+        && request.auth.token.email.matches('管理者メルアド@tryforth.com');
+    }  
  }
}

もちろん、個人別のアカウントに応じたルールがあらかじめ設定されてあれば、こんなバックドアを作らなくともOKです。
ここでは軽く流しますが、セキュリティルールはFirestoreのキモです。
説明を軽視したくは無いのですが、この記事内で解説するには私の知識では不十分なので、Firebase本家のセキュリティルールとかをご参照ください。

QiitaであればFirestore rules tipsあたりがオススメで、
書籍だと2019の夏コミで頒布されたFirestore Mastery非常におすすめ ですので、ダイマしときます。

スプレッドシート(GoogleAppsScript)側の処理

GAS部分の役割は主に3つで

  • サイドバーを作成する
  • シートを解析してサイドバーにJSONを渡す
  • サイドバーから渡されたJSONをシートに書き込む

となります。

コード.gs

まずはGASのスクリプトファイルからです。
日本語のデフォルトだと コード.gs となってるので、今回はそのまま使いました。
コードgs.png

コード.gs
/**
 * Spreadsheetを開いたときに拡張メニューを追加する
 */
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('拡張メニュー')
    .addItem('サイドバーを開く', 'showSidebar')
    .addToUi();
}

/**
 * サイドバーを開く
 */
function showSidebar() {

  // 読み込みの際はファイル名の末尾に .html が追加される
  var ui = createHtmlOutput('index')
    .setTitle('サイドバー')
    .setSandboxMode(HtmlService.SandboxMode.IFRAME);

  SpreadsheetApp.getUi().showSidebar(ui);
}

/**
 * GASプロジェクト内にあるファイルを読み込みhtmlテンプレートを作成する
 *
 * @param {String} filename
 * @return {Object} HtmlOutput
 *
 * HtmlOutput:
 *   https://developers.google.com/apps-script/reference/html/html-output
 */
function createHtmlOutput(filename) {

  var htmlTemplate = HtmlService.createTemplateFromFile(filename);
  // https://developers.google.com/apps-script/reference/html/html-template

  // ここでHTML側に渡す値があればを登録する(今回は使わない)
  // htmlTemplate.hoge = 'fuga';

  // 登録した値から最終的なHtmlOutputを生成し返す
  return htmlTemplate.evaluate();
}

/*
 * htmlTemplate.evaluateが実行されたとき
 *  <?!= include('hogehoge.html'); ?>
 * の部分を指定のファイル内容で展開する
 */
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

/**
 * アクティブシートの内容を解析しJSON文字列として返す
 * 今回の作例ではサイドバー側から呼び出されることを想定している
 */
function GASreadJSONFromActiveSpreadsheet() {
  var sheet = SpreadsheetApp.getActiveSheet();

  var result = getSheetDataJSON(sheet, 1, 0, 2);

  return JSON.stringify(result);
}

/**
 * JSON文字列をアクティブなSpreadsheetに読み込む
 * 今回の作例ではサイドバーからJSONデータが送られてくる事を想定している
 */
function GASsendToSpreadsheet(json) {

  var schema = {};

  var types = ['docid'];
  var keys = ['docid'];

  var data = JSON.parse(json)

  // 入力されたJSONの構造がバラバラな事も想定し
  // 全てのデータをスキャンしてフィールド名とその型を列挙する
  data.forEach(function (doc) {
    var fields = doc.value;
    for (var key in fields) {
      if (schema[key] == null) {
        keys.push(key);
        types.push(fields[key].type);

        schema[key] = {
          type: fields[key].type,
          index: keys.length - 1
        };
      }
    }
  })

  var values = [];    // シートに書き込む配列を定義
  values.push(types); // 1行目は型情報
  values.push(keys);  // 2行目はキーの名前

  data.forEach(function (doc) {
    var row = [doc.key];
    var fields = doc.value;
    for (var key in fields) {
      var index = schema[key].index;
      var field = fields[key];

      // 実際に渡されたデータのフィールド型を考慮して変換する
      switch (schema[key].type) {
        case 'Timestamp': // Timestamp型はDate型としてパース
          var time = Date.parse(field.value);
          row[index] = new Date(time);
          break;

        case 'Array':
        case 'Map':
        case 'GeoPoint':  // GeoPoint型はフロント側で2要素の数字配列に変換済み
          row[index] = JSON.stringify(field.value);
          break;

        default:
          row[index] = field.value;
      }
    }
    values.push(row);
  })

  // 現在のスプレッドシートに追加
  var sheet = SpreadsheetApp.getActiveSheet();
  sheet.getRange(1, 1, values.length, values[0].length).setValues(values);
}

/**
 * json objectを取得する取得
 * headerRow行は要素の名前
 * typesRow行は型情報
 * ['docid', 'String', 'Number', 'Boolean', 'Array', 'Map', 'DocumentReference', 'Timestamp', 'GeoPoint', 'null']
 * dataRow行以降に実データ
 */
function getSheetDataJSON(sheet, headerRow, typesRow, dataRow) {

  var values = sheet.getDataRange().getValues();  // シートのデータ取得
  var columns = values[headerRow].length;
  var rows = values.length;

  // docid列の有無チェック
  if (values[typesRow].indexOf('docid') == -1) {
    throw new Error('1行目にdocid列が定義されていません');
  }

  // 型情報行のチェック
  var definedTypes = ['docid', 'String', 'Number', 'Boolean', 'Array', 'Map', 'DocumentReference', 'Timestamp', 'GeoPoint', 'null'];
  values[typesRow].forEach(function (item, index) {
    if (definedTypes.indexOf(item) == -1) {
      sheet.getRange(typesRow + 1, index + 1).setBackground("yellow");
      sheet.getRange(typesRow + 1, index + 1).setComment(definedTypes.toString() + 'の中から設定してください');
    }
  });

  var hasValidateError = false;
  var jsonArray = [];

  for (var row = dataRow; row < rows; row++) { // 先頭行はIndexなので2行目から
    var json = {};
    for (var column = 0; column < columns; column++) {
      var key = values[headerRow][column];
      var type = values[typesRow][column];
      var value = values[row][column];
      var entry = {};

      if (key.length == 0) continue;

      if (value === 'null') {
        json[key] = { value: null, type: "null" };
        continue;
      }

      try {
        switch (type) {
          case 'docid':
          case 'String':
            entry.type = type;
            entry.value = String(value);
            break;
          case 'Number':
            if (value.constructor.name === type) {
              entry.type = type;
              entry.value = value;
            } else {
              throw new Error('数字ではない');
            }
            break;
          case 'Boolean':
            if (value.constructor.name === type) {
              entry.type = type;
              entry.value = value;
            } else {
              throw new Error('Boolではない');
            }
            break;
          case 'DocumentReference':
            entry.type = type;
            entry.value = String(value);
            break;
          case 'Map':
            //MapはObjectとして扱う
            var result = JSON.parse(value);
            if (result.constructor.name === 'Object') {
              entry.type = type;
              entry.value = result;
            } else {
              throw new Error('JSON.parse可能なObjectになっていない');
            }
            break;
          case 'Array':
            var result = JSON.parse(value);
            if (result.constructor.name === 'Array') {
              entry.type = type;
              entry.value = result;
            } else {
              throw new Error('JSON.parse可能な Arrayになっていない');
            }
            break;
          case 'Timestamp':
            if (value.constructor.name === 'Date') {
              //ここではDate型として扱うがFirebaseに渡す際はTimestamp型換する必要がある
              entry.type = type;
              entry.value = value;
            } else {
              throw new Error('Date型になっていない');
            }
            break;
          case 'GeoPoint':
            var result = JSON.parse(value);
            if ((result.constructor.name === 'Array')
              && (result.length == 2)
              && (typeof result[0] === 'number')
              && (typeof result[1] === 'number')
            ) {

              if ((Math.abs(result[0]) <= 90) && (Math.abs(result[1]) <= 180)) {
                entry.type = type;
                entry.value = result;
              } else {
                throw new Error('経度-90~90 緯度-180~180で設定してください');
              }
            } else {
              throw new Error('数字2つのArrayになっていない');
            }
            break;

          default:
            throw new Error('該当する型情報がありません');
        }
        json[key] = entry;
      } catch (e) {
        // 色付けてエラーコメントを残す
        sheet.getRange(row + 1, column + 1).setBackground("yellow");
        sheet.getRange(row + 1, column + 1).setComment(e);
        hasValidateError = true;
      }
    }
    //Logger.log(json);
    jsonArray.push(json);
  }

  if (hasValidateError) {
    throw new Error('データの入力ミスを修正してください');
  }

  return jsonArray;
}

サイドバーを作成する

Spreadsheetを開くとonOpenが呼ばれ、サイドバーを表示するためのメニューが登録されます。
メニューから実行すると、後述するindex.html以下フロント部分のHTMLが構築され、サイドバーとして表示されます。

このあたりの流れは2018年のAdventCalenderではGAS+Vue.jsでSpreadsheetのサイドバーを作成する記事を書かせて頂いたので今回は詳細を省きます。

シートを解析してサイドバーにJSONを渡す

データにはFirestoreの同じ名前で型情報を持たせ、GASでValidationをしてからサイドバーに渡すようにしました。
こんな感じでシートから読み出し時にエラーの有った個所をメモ等でマークしてくれます。
validation.gif

個人的にMap Array DocumentReferenceへの対応が必須だったのでFirestoreの型には可能な限り対応してみました。
(逆にGeoPointは全く使わないので上手く動作してるかはよくわからんです)

なお、Spreadsheetによる編集は(使用者からすれば)とても柔軟でいい具合ですが、型情報がガバガバすぎて色々トラブルの原因が詰まっています。
特にDatetime型は「セルの表示形式」による影響でNumberになったりStringになったりと色々面倒です。
この辺に関しては別記事でまとめておきましたので、良ければご覧ください。

サイドバーから渡されたJSONをシートに書き込む

後述するmain.js.htmlからGASsendToSpreadsheetが呼び出されJSONデータが渡ってきます。
Firestoreから送られてきたJSONの内容をいい感じシート情報に変換しますが、先ほどのシート情報の解析する場面とは違い、データベースから送られてきたモノなので型情報に細かなValidationは不要です。
Firestore特有の型情報を軽くごにょごにょして、アクティブシートに書き込みます。

サイドバー(Vue.js+FirebaseSDK)側の処理

これ以降はスクリプトエディタに保存はされているものの、GASっぽい処理皆無で、普通のHTML5+Javascriptの世界になります。
諸々のJavascriptライブラリはCDNから読み込みんでいる関係で、特にVue.jsはNode.jsとかで開発している様子とは異なります。
(Claspを使えるようであれば、Node.jsで開発するのがラクだと思います)
コピペで動く事をウリ(?)にしてるので、この形式となっています。

firebase.js.html

まずはfirebase.js.htmlです
ここの内容はお使いのプロジェクトによってパラメータが変わります。
firebase_js.png
Firebaseコンソールからプロジェクトを選び、設定のどっかにこんなページがあるんで上手いこと見つけてください。
FirebaseCDN_script.png

見つけたらこのJavascriptっぽいコードfirebase.js.htmlにコピペしてください。
なお、Firebaseのプロジェクト設定次第では、他にもいろいろなオマケのライブラリがついてくるかもしれないです。

今回の作例ではanalyticsは使わないので、

firebase.js
<script src="https://www.gstatic.com/firebasejs/7.5.2/firebase-analytics.js"></script>
firebase.analytics();

の部分は削除しました。(残していても動きます)

なお、読み込むCDNライブラリのバージョンは揃えてください。
記事執筆時点では7.5.2でしたが、バージョンは日々変わると思うので、変更があったら以降のソースの7.5.2の部分を適宜置き換えてください。

Firebaseのセキュリティ

なんとなく各種情報にボカシを入れてありますが、実際にはこれらの情報は秘密でも何でもなく、FirebaseのWebアプリでhtmlのソースを見ればダダ漏れです。
ですのでセキュリティに関しては

  • Firebase Authentication
  • データベースのセキュリティルール
  • CloudFunctionsでの実装

等を組み合わせ、適切に設定する必要があります。
(繰り返しになりますが、データベースのセキュリティルール設定がキモになります)

main.js.html

main.js.htmlは実際にFirebaseを読んだり書いたり、シートにJSONを送ったり読み込んだりする部分です。
Vue.jsのコンポーネントとして登録する関係で、DOM要素はテンプレート構文に詰め込まれており可読性はイマイチです。
準備不足で色々雑なので、申し訳ありませんが解説は省略させてください。
そもそもGASのAdvent calenderなので…コピペで動きますし!

main.js.html
<script>
  var main = {
    name: "main",
    template: `
    <div>
      <b-loading :active.sync="isLoading"></b-loading>
      <div class="control-block">
        <b-field label="Target Collection" label-position="on-border" :type="existCollection? 'is-success' : 'is-danger'">
          <b-input style="width:200px" v-model="collectionName" placeholder="collection id" type="text" icon="firebase" @input="collectionChange"></b-input>
          <p class="control">
            <b-button class="button is-info" @click="checkCollection">find</b-button>
          </p>
        </b-field>
      </div>
      <div class="table-block mt-16">
        <b-table narrowed striped :data="tableData" ref="table" hoverable
          :mobile-cards="false" detailed :show-detail-icon="false" detail-key="key"
          paginated pagination-position="top" pagination-simple per-page="10">
          <template slot-scope="props">
            <b-table-column field="key" label="document ID">
              <a class="is-size-7" @click="toggle(props.row)">{{ props.row.key }}</a>
            </b-table-column>
            <b-table-column label>
              <b-tooltip v-if="props.row.info" :label="props.row.info.message" position="is-left" animated>
                <b-icon size="is-small" :icon="props.row.info.icon" type="is-dark" />
              </b-tooltip>
            </b-table-column>
          </template>

          <template slot="detail" slot-scope="props">
            <div class="is-size-7" v-for="(item, key) in props.row.value" :key="item.key">
              <span>{{ key }}</span>
              <span class="is-italic">&lt;{{ item.type }}&gt;</span>
              <span>: {{ item.value }}</span>
            </div>
          </template>
          <template slot="empty">
            <div class="content has-text-grey has-text-centered box">
              <p>
                <b-icon icon="emoticon-sad" size="is-large"></b-icon>
              </p>
              <p>データは読み込まれていません</p>
              <b-button expanded icon-left="google-drive" @click="readSpreadsheet">シートから読み込む</b-button>
              <b-button expanded icon-left="firebase" :disabled="!existCollection" @click="listCollection">Firestoreから読み込む</b-button>
            </div>
          </template>
        </b-table>
      </div>
      <div class="control-block">
        <div v-if="tableData.length>0">
          <b-button expanded icon-left="close" @click="initTableDate">読み込み内容をクリア</b-button>
          <hr/>
          <div v-if="docSource=='firestore'">
            <b-button expanded size="is-large" type="is-success" icon-left="google-drive" @click="sendToSpreadsheet">シートに読み込む</b-button>
          </div>
          <div v-if="docSource=='spreadsheet'">
            <b-button expanded size="is-large" type="is-warning" icon-left="firebase" :disabled="!existCollection" @click="writeDocuments">Firestoreに書き込む</b-button>
          </div>
        </div>
      </div>
    </div>
    `,
    data: function() {
      return {
        documents: {},
        docSource: "",
        tableData: [],
        collectionName: "",
        docSchema: null,
        isLoading: false
      };
    },
    computed: {
      existCollection() {
        return this.docSchema ? true : false;
      }
    },
    methods: {
      collectionChange(e) {
        this.docSchema = null;
      },
      initTableDate() {
        this.tableData = [];
        this.documents = {};
        this.docSource = "";
      },

      sendToSpreadsheet() {
        this.isLoading = true;

        google.script.run
          .withSuccessHandler(this.sendSpreadsheetSuccess)
          .withFailureHandler(this.sendSpreadsheetFail)
          .GASsendToSpreadsheet(JSON.stringify(this.tableData));
      },
      sendSpreadsheetSuccess(result) {
        this.tableData = [];
        this.isLoading = false;
      },
      sendSpreadsheetFail(result) {
        console.log(result);
        this.isLoading = false;
      },

      readSpreadsheet() {
        this.isLoading = true;
        google.script.run
          .withSuccessHandler(this.readSpreadsheetSuccess)
          .withFailureHandler(this.readSpreadsheetFail)
          .GASreadJSONFromActiveSpreadsheet();
      },
      readSpreadsheetSuccess(result) {
        this.documents = JSON.parse(result);
        this.docSource = "spreadsheet";

        this.tableData = this.documents.map((doc, index) => {
          let result = {
            key: null,
            info: { icon: "google-drive" },
            value: {}
          };

          for (let k in doc) {
            if (doc[k].type == "docid") {
              if (doc[k].value.length) {
                result.key = doc[k].value;
              }
              // docidをキーとして登録
            } else {
              // docid以外を内容物として登録
              result.value[k] = doc[k];
            }
          }
          this.isLoading = false;

          return result;
        });
      },
      readSpreadsheetFail(result) {
        console.log(result);
        this.isLoading = false;
      },

      toggle(row) {
        this.$refs.table.toggleDetails(row);
      },

      async writeDocuments() {
        this.isLoading = true;
        for (let doc of this.tableData) {
          let result = {};
          for (let field in doc.value) {
            switch (doc.value[field].type) {
              case "String":
              case "Number":
              case "Boolean":
              case "Map":
              case "Array":
                result[field] = doc.value[field].value;
                break;

              case "DocumentReference":
                result[field] = db.doc(doc.value[field].value);
                break;

              case "Timestamp":
                result[field] = firebase.firestore.Timestamp.fromDate(
                  new Date(doc.value[field].value)
                );
                break;

              case "GeoPoint":
                let geo = doc.value[field].value;
                result[field] = new firebase.firestore.GeoPoint(geo[0], geo[1]);
                break;

              default:
                break;
            }
          }
          if (doc.key.length) {
            //既存ドキュメントを更新(無ければ新規作成)
            await db
              .collection(this.collectionName)
              .doc(doc.key)
              .set(result, { merge: true })
              .then(() => {
                doc.info.icon = "check";
              })
              .catch(error => {
                doc.error = error;
              });
          }
        }
        this.isLoading = false;
      },

      // コレクションの存在確認のため1つだけドキュメントを読み込む
      checkCollection() {
        this.docSchema = null;
        if (!this.collectionName.length) return;

        db.collection(this.collectionName)
          .limit(1)
          .get()
          .then(docSnapshot => {
            docSnapshot.docs.forEach(doc => {
              const fields = doc.data();
              this.docSchema = this.parseFields(fields);
            });
          });
      },

      parseFields(fields) {
        let result = {};
        for (let key in fields) {
          // firebase SDKをCDNから読み込むと minifyされてる関係で prototype.constructor.nameが
          // JavascriptのPrimitive型以外のClass名を正確に取得できない
          // そのため特徴的なプロパティ名の有無でFieldTypeを判別している
          let value;

          if (fields[key] == null) {
            type = "null";
            value = null;
          } else if (fields[key].path != null) {
            // CDNのfirebase SDKだとclass名が Yd で登録されれているっぽい
            type = "DocumentReference";
            value = fields[key].path;
          } else if (fields[key].seconds != null) {
            // CDNのfirebase SDKだとclass名が so で登録されれているっぽい
            type = "Timestamp";
            value = fields[key].toDate();
          } else if (fields[key].latitude != null) {
            // CDNのfirebase SDKだとclass名が $h で登録されれているっぽい
            type = "GeoPoint";
            value = [fields[key].latitude, fields[key].longitude];
          } else {
            // JavascriptのPrimitive型っぽいのは
            // prototype.constructor.nameをtype名として採用
            type = fields[key].constructor.name;
            if (type == "Object") {
              // ただし、Object型はFirestore的にはMap型なのでリネーム
              type = "Map";
            }
            value = fields[key];
          }
          result[key] = {
            type,
            value
          };
        }
        return result;
      },

      async listCollection() {
        this.isLoading = true;
        const snapShot = await db.collection(this.collectionName).get();

        let obj = {};
        let arr = [];

        snapShot.docs.forEach(doc => {
          const fields = doc.data();
          const result = this.parseFields(fields);
          obj[doc.id] = fields;
          arr.push({
            key: doc.id,
            info: { icon: "firebase", message: doc.id },
            value: result
          });
        });
        this.documents = obj;
        this.docSource = "firestore";
        this.tableData = arr;

        this.isLoading = false;
      }
    }
  };
</script>

firebase-auth.js.html

Firebaseへのログイン処理とGUI表示はFirebaseUIを使いました。

FirebaseUI でウェブアプリに簡単にログイン機能を追加する
https://firebase.google.com/docs/auth/web/firebaseui?hl=ja

↑この辺からいい感じにコピペして、Vueのコンポーネントとして最小限の機能を切り出しまします。

firebase-auth.js.html
<script src="https://www.gstatic.com/firebasejs/ui/4.3.0/firebase-ui-auth__ja.js"></script>
<link type="text/css" rel="stylesheet" href="https://www.gstatic.com/firebasejs/ui/4.3.0/firebase-ui-auth.css" />

<script>
  // Initialize the FirebaseUI Widget using Firebase.
  const ui = new firebaseui.auth.AuthUI(firebase.auth());
  const firebaseAuth =
  {
    name: "firebase-auth",
    template: '<div><div id="firebaseui-auth-container"></div></div>',
    mounted() {
      // The start method will wait until the DOM is loaded.
      ui.start("#firebaseui-auth-container",
        {
          signInFlow: 'popup',
          signInOptions: [
            firebase.auth.GoogleAuthProvider.PROVIDER_ID,
            // firebase.auth.FacebookAuthProvider.PROVIDER_ID,
            // firebase.auth.TwitterAuthProvider.PROVIDER_ID,
            // firebase.auth.GithubAuthProvider.PROVIDER_ID,
            // firebase.auth.EmailAuthProvider.PROVIDER_ID,
            // firebase.auth.PhoneAuthProvider.PROVIDER_ID,
          ],
          callbacks: {
            signInSuccessWithAuthResult: function (authResult, redirectUrl) {
              var user = authResult.user;
              var credential = authResult.credential;
              var isNewUser = authResult.additionalUserInfo.isNewUser;
              var providerId = authResult.additionalUserInfo.providerId;
              var operationType = authResult.operationType;
              // Do something with the returned AuthResult.
              // Return type determines whether we continue the redirect automatically
              // or whether we leave that to developer to handle.
              return false;
            },
            signInFailure: function (error) {
              // Some unrecoverable error occurred during sign-in.
              // Return a promise when error handling is completed and FirebaseUI
              // will reset, clearing any UI. This commonly occurs for error code
              // 'firebaseui/anonymous-upgrade-merge-conflict' when merge conflict
              // occurs. Check below for more details on this.
              return handleUIError(error);
            },
          },
        });
      ui.disableAutoSignIn();
    },
  };
</script>

サイドバーでfirebase-authするばあい、signInFlowは必ず 'popup'を使用してください。
スマホでFirebase認証の際はsignInFlowをredirectにするのがセオリーかと思いますが、GASで表示できるHTMLは幾重ものiframeの牢獄に閉じ込められているため、リダイレクトやリロードは鬼門です。

なお、今回の作例ではGoogleのログインのみを想定していますが、必要に応じて他のAuthを使いたい場合、signInOptionsの部分を適宜コメントアウトすれば、それっぽいインターフェイスが出現します。

signInOptions: [
    firebase.auth.GoogleAuthProvider.PROVIDER_ID,
    firebase.auth.FacebookAuthProvider.PROVIDER_ID,
    firebase.auth.TwitterAuthProvider.PROVIDER_ID,
    firebase.auth.GithubAuthProvider.PROVIDER_ID,
    firebase.auth.EmailAuthProvider.PROVIDER_ID,
    firebase.auth.PhoneAuthProvider.PROVIDER_ID,
],

auth_list.png

index.html

サイドバーの基本部分の処理を行ってます。

<?!= include('firebase.js.html'); ?>

みたいなキモい記述がありますが、ここにはGASのプロジェクトに登録されているファイルの内容がそのまま流し込まれます。
今回の作例では5つのファイルにソースが分割されていますが、"コード.gs" 以外の4つのhtmlは一つの大きなhtmlとしてGASで結合されてからサイドバーにレンダリングされています。

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="stylesheet" href="https://unpkg.com/buefy/dist/buefy.min.css" />
    <link
      rel="stylesheet"
      href="https://cdn.materialdesignicons.com/2.5.94/css/materialdesignicons.min.css"
    />
    <style>
      html {
        overflow-y: auto;
      }
      [v-cloak] {
        display: none;
      }

      .control-block {
        margin: 16px 8px 0px 8px;
      }
      .table-block {
        margin: 0px;
      }
    </style>
  </head>

  <body>
    <div id="app">
      <div class="container" v-cloak>
        <b-navbar fixed-bottom type="is-info">
          <template slot="brand">
            <b-navbar-item>
              <b-icon class="media-left" icon="account"></b-icon>
              <h3>{{ username }}</h3>
            </b-navbar-item>
          </template>
          <template slot="end">
            <b-navbar-item tag="div">
              <b-field label="hostname" label-position="on-border">
                <b-input :value="hostname" icon="earth"></b-input>
                <p class="control">
                  <b-button
                    class="button is-primary"
                    @click="setHostnameToClipboard"
                    >copy</b-button
                  >
                </p>
              </b-field>
            </b-navbar-item>

            <b-navbar-item tag="div">
              <div class="media">
                <b-icon class="media-left" icon="account"></b-icon>
                <div class="media-content">
                  <h3>{{ username }}</h3>
                  <small>{{ useremail }}</small>
                </div>
              </div>
            </b-navbar-item>
            <hr class="dropdown-divider" aria-role="menuitem" />
            <b-navbar-item href="#" @click="logout">
              <div class="media">
                <b-icon class="media-left" icon="logout"></b-icon>
                <div class="media-content">
                  <h3>Firebaseからログアウト</h3>
                </div>
              </div>
            </b-navbar-item>
          </template>
        </b-navbar>
      </div>
      <router-view></router-view>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10"></script>
    <script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
    <script src="https://unpkg.com/buefy/dist/buefy.min.js"></script>

    <?!= include('firebase.js.html'); ?>
    <script src="https://www.gstatic.com/firebasejs/7.5.2/firebase-auth.js"></script>
    <script src="https://www.gstatic.com/firebasejs/7.5.2/firebase-firestore.js"></script>

    <?!= include('main.js.html'); ?>
    <?!= include('firebase-auth.js.html'); ?>

    <script>
      const db = firebase.firestore();

      const router = new VueRouter({
        mode: "history",
        routes: [
          { path: "/main", component: main },
          { path: "/auth", component: firebaseAuth }
        ]
      });
      new Vue({
        el: "#app",
        router,
        created() {
          this.currentUser = firebase.auth().currentUser;
          firebase.auth().onAuthStateChanged(() => {
            this.currentUser = firebase.auth().currentUser;
            if (this.currentUser) {
              this.$router.push("/main");
            } else {
              this.$router.push("/auth");
            }
          });
        },
        data: {
          currentUser: null
        },
        methods: {
          logout() {
            firebase
              .auth()
              .signOut()
              .then(() => {
                console.log("ログアウトしました");
                this.$router.push("/auth");
              })
              .catch(error => {
                console.log(`ログアウト時にエラーが発生しました (${error})`);
              });
          },
          setHostnameToClipboard() {
            navigator.clipboard.writeText(this.hostname);
          }
        },
        computed: {
          hostname() {
            return location.host;
          },
          username() {
            return this.currentUser
              ? this.currentUser.displayName
              : "未ログイン";
          },
          useremail() {
            return this.currentUser ? this.currentUser.email : "<no email>";
          }
        }
      });
    </script>
  </body>
</html>

index.htmlでは、Firebase関連のライブラリや、Vue.jsとRouter、コンポーネントのBuefy、Fontアイコン等を読み込んでいます。
その後、firebase-authでログインが完了していればmainページ(コンポーネント)を。
未ログインならauthページ(コンポーネント)を表示します。

状況によってはログアウト処理が必要になるため、サイドバーの下部のナビゲーションで行えるようにしました。

ちなみにログアウト後に再ログインする場合、最後に認証したユーザー情報で自動ログインします。
個人的にはアカウントを切り替えて使う必要があるのですが、ユーザー選択のpopupを出す方法がよくわからなくて困ってます。(↓この画面)
popup.png

ご存じの方いましたら教えてください!(切実)

まとめ

GAS+Firebaseはいいぞ!

おわりに

記事作成に当たってスケジュール配分をミスってしまい色々雑なのですが、AdventCalender的にそろそろ時間切れなので一旦終わっときます。
色々怪しい部分もあるので、ご指摘有ればマサカリオナシャス!センセンシャル!

16
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
16