⬆️

Firebase Admin SDK を使って Firestore と Cloud Storage を CLI で操作してみる

2024/12/21に公開

はじめに

バックエンドのサーバーから Firebase Admin SDK を使って Firebase のリソースにアクセスする記事は調べれば出てくる。実際公式の説明もそのような使い方の意図を感じる。
しかし個人開発の場合で、開発環境(フロントエンド)から Firebase のサービスにアクセスしたいということがある。例えばフロントエンドコードとは別にサイトのコンテンツをデプロイできるようなスクリプトなどだ。
セキュリティ面ではあまりよろしくないのかもしれないが、基本的に自分だけしか使わないので(App Check 等を通り越せるように)認証まわりを整えるよりかは Firebase Admin SDK を使ってサクッと実装・動作させたい。

そこで本記事では、フロントエンドの開発環境から Firestore や Cloud Storage にアクセスできるちょっとした CLI ツールを作ってみる。
コードをそれなりに ChatGPT に書いてもらったので、あまり良くない記述が混じっているかもしれないが、その際は指摘してもらえるとありがたい。

前提

  • pnpm create viteなどによってすでにフロントエンド開発環境が構築されているものとする。
    (筆者はパッケージ管理ツールとしてpnpmを使っているが、npmなどで適宜読み替えても問題ないはずである。)
  • GitHub などのサービスを利用してコードを管理している。
  • Firebase にもプロジェクトが存在し、Firebase CLI も導入済みで、リポジトリと Firebase プロジェクトとの紐付けが完了している。
  • Firestore や Cloud Storage について、データベースおよびバケットがすでに Firebase プロジェクト内に用意されている。

もし Firebase の利用自体が初めてであれば、まずは以下の拙著やネット上の入門記事等を参照願いたい。

https://zenn.dev/takanari_dev/articles/2024-01-29-firebase-web-app

目標

最終的には以下のように実行できる機能を実装する。

pnpm exec tsx scripts/typescript/firestore-cli.ts set collection data1 -p ./data/data1.json
pnpm exec tsx scripts/typescript/firestore-cli.ts set collection data1 -d '{"name": "test", , "value": 1}'
pnpm exec tsx scripts/typescript/firestore-cli.ts get collection data1
pnpm exec tsx scripts/typescript/storage-cli.ts upload ./files storage/items

準備

基本的には以下の公式ガイドの通りに進めたが、いくつか使いやすいようにアレンジする。
https://firebase.google.com/docs/admin/setup?hl=ja

サービスアカウント情報を取得

公式ガイドに従ってサービスアカウント用の秘密鍵ファイルを生成し取得する。

サービス アカウント用の秘密鍵ファイルを生成するには:

  1. Firebase コンソールで、[設定] > [サービス アカウント] を選択します。
  2. [新しい秘密鍵の生成] をクリックし、[キーを生成] をクリックして確定します。
  3. キーを含む JSON ファイルを安全に保管します。

https://firebase.google.com/docs/admin/setup?hl=ja#initialize_the_sdk_in_non-google_environments

作成してダウンロードしてきた JSON ファイルをわかりやすいようにserviceAccountKey.jsonにリネームしておく。
次にリポジトリルートにsecretsフォルダを作成し、serviceAccountKey.jsonを作成したフォルダの中に入れる。

serviceAccountKey.jsonはかなり機密情報(これでプロジェクトいじり放題)なため、git の管理下に置かないようにした方が良い。そのため.gitignoreに書いておく必要がある。

.gitignore
+ secrets/

必要なツールの導入

Firebase Admin SDK を導入する。開発環境のみで使いたいため-Dをつける。

pnpm add -D firebase-admin

また TypeScript で CLI ツールを作りやすくするためにCommander.js[1]というライブラリを使う。

pnpm add -D commander

それとターミナル上で TypeScript コードをそのまま実行したいためtsxというツールも導入しておく。

pnpm add -D tsx

ちなみにts-nodeというツールもあるがERR_UNKNOWN_FILE_EXTENSIONが発生してうまく動かなかった。
https://zenn.dev/galapagos/articles/thank-you-tsx

スクリプトの作成

ここから、Firebase Admin SDK を使って CLI ツールを作成していく。

SDK 初期化

公式のガイドによれば、

サービス アカウントを介して認可する場合、アプリケーションの認証情報を指定するには 2 つの選択肢があります。GOOGLE_APPLICATION_CREDENTIALS環境変数を設定することも、サービス アカウント キーへのパスをコード内で明示的に示すこともできます。 1 つ目の選択肢のほうが安全であるため、そちらを強くおすすめします。

とあるので、コードを書く前にGOOGLE_APPLICATION_CREDENTIALSを設定する。
VS Code 限定だが、以下のように.vscode/settings.jsonを作成してそこに書いておくと、VS Code上のターミナルのみでこの環境変数が自動設定されるので個人的にはオススメである。[2]

.vscode/settings.json
{
  "terminal.integrated.env.osx": {
    "GOOGLE_APPLICATION_CREDENTIALS": "${workspaceFolder}/secrets/serviceAccountKey.json",
  }
}

準備ができたのでscripts/typescript/firebaseフォルダを作成し、admin.tsファイルに SDK を初期化するコードを書く。

scripts/typescript/firebase/admin.ts
import admin from "firebase-admin";

admin.initializeApp({
  credential: admin.credential.applicationDefault(),
  storageBucket: "<YourProjectId>.firebasestorage.app", // Cloud Storage用バケットを指定
});
export const db = admin.firestore();
export const storage = admin.storage();

admin.credential.applicationDefault()GOOGLE_APPLICATION_CREDENTIALSの環境変数に設定されたファイルパスを見にいき、認証情報を与えるものである。
このadmin.tsは初期化済みのdbstorageを提供する。

2つ目の選択肢による実装

ChatGPT に書いてもらった。
serviceAccountKey.jsonのファイルの場所がscripts/typescript/firebase/secrets/serviceAccountKey.jsonであるとする。)

scripts/typescript/firebase/admin.ts
import admin from "firebase-admin";
import { ServiceAccount } from "firebase-admin";
import serviceAccount from "./secrets/serviceAccountKey.json";

// Firebase Admin SDK の初期化(すでに初期化済みでない場合のみ)
if (!admin.apps.length) {
  admin.initializeApp({
    credential: admin.credential.cert(serviceAccount as ServiceAccount),
    storageBucket: "<YourProjectId>.firebasestorage.app", // Cloud Storage用バケットを指定
  });
}

export const db = admin.firestore();
export const storage = admin.storage();

Firestore と接続する

複雑なクエリには対応せず、ドキュメントのgetsetのみを実装する。

実際にやり取りするスクリプト

CLI の設計と切り離したgetsetの処理をまずは実装する。

scripts/typescript/firebase/firestore.ts
import admin from "firebase-admin";
import { db } from "./admin";

// ドキュメントの取得
export async function getDocument(collection: string, docId: string) {
  const docRef = db.collection(collection).doc(docId);
  const doc = await docRef.get();

  if (!doc.exists) {
    console.log(`No document found with ID: ${docId}`);
    return;
  }

  const data = doc.data();
  console.log("Document Data:", data);
}

// ドキュメントの作成
export async function setDocument(
  collection: string,
  docId: string,
  data: any
): Promise<void> {
  try {
    const docRef = db.collection(collection).doc(docId);
    const docSnapshot = await docRef.get();
    if (docSnapshot.exists) {
      // 更新の場合、`updatedAt`を更新
      await docRef.update({
        ...data,
        updatedAt: admin.firestore.FieldValue.serverTimestamp(),
      });
      console.log(`Document ${docId} updated successfully in ${collection}.`);
    } else {
      // 作成の場合、`createdAt`と`updatedAt`を設定
      await docRef.create({
        ...data,
        createdAt: admin.firestore.FieldValue.serverTimestamp(),
        updatedAt: admin.firestore.FieldValue.serverTimestamp(),
      });
      console.log(`Document ${docId} added successfully to ${collection}.`);
    }
  } catch (error) {
    console.error("Error adding/updating document:", error);
  }
}

getDocumentの方はとてもシンプルで、collectiondocIdを指定すれば、ドキュメントの内容を取得して表示する。

setDocumentは少し複雑で、データがすでに Firestore にある場合とない場合で挙動が異なる。これは自動的にupdatedAtcreatedAtを記録するためで、必要がないならもっとシンプルに書けると思われる。

CLI 化

次に作成した処理を CLI として利用するためのラッパーを書く。

scripts/typescript/firestore-cli.ts
import * as fs from "fs";
import * as path from "path";
import { Command } from "commander";
import { getDocument, setDocument } from "./firebase/firestore";

function readJsonFile(filePath: string): any {
  const absolutePath = path.resolve(filePath);
  const fileContents = fs.readFileSync(absolutePath, "utf-8");
  return JSON.parse(fileContents);
}

const program = new Command();

// get サブコマンド
program
  .command("get <collection> <docId>")
  .description("Get document by collection and document ID")
  .action(async (collection: string, docId: string) => {
    await getDocument(collection, docId);
  });

// set サブコマンド
program
  .command("set <collection> <docId>")
  .description("Set document with createdAt and updatedAt")
  .option("-p, --path <path>", "File data to set in the document")
  .option("-d, --data <data>", "Data to set in the document", JSON.parse)
  .action(
    async (
      collection: string,
      docId: string,
      options: { path: string; data: any }
    ) => {
      const { path, data } = options;
      if (path) {
        await setDocument(collection, docId, readJsonFile(path));
        return;
      }
      await setDocument(collection, docId, data);
    }
  );

program.parse(process.argv);

getコマンドはただのgetDocumentのラッパーになっているだけ。

setコマンドはオプションで JSON ファイルなのか(--path)、直接 JSON 文字列を与えるか(--data)が選べるようになっている。

使ってみる

以下のような形で実行すればちゃんと動いていることがわかる。

pnpm exec tsx scripts/typescript/firestore-cli.ts set collection data1 -p ./data/data1.json
pnpm exec tsx scripts/typescript/firestore-cli.ts set collection data1 -d '{"name": "test", "value": 1}'
pnpm exec tsx scripts/typescript/firestore-cli.ts get collection data1

Cloud Storage に接続する

アップロードするだけの機能に絞るが、フォルダを指定したときもその中身のファイルもアップロードできるようにする。

実際にやり取りするスクリプト

Cloud Storage にアップロードする直接的な処理はuploadFile関数だけであり、それ以外はフォルダを対象にするための実装である。

scripts/typescript/firebase/storage.ts
import { storage } from "./admin";
import * as path from "path";
import * as fs from "fs";
import * as util from "util";

const readdir = util.promisify(fs.readdir);
const stat = util.promisify(fs.stat);

// ディレクトリ内のファイルを再帰的に取得する関数
async function getFilesInDirectory(directoryPath: string): Promise<string[]> {
  const files: string[] = [];
  const items = await readdir(directoryPath);

  for (const item of items) {
    const fullPath = path.join(directoryPath, item);
    const fileStat = await stat(fullPath);

    if (fileStat.isDirectory()) {
      // ディレクトリなら再帰的に検索
      const nestedFiles = await getFilesInDirectory(fullPath);
      files.push(...nestedFiles);
    } else {
      // ファイルならリストに追加
      files.push(fullPath);
    }
  }

  return files;
}

// ファイルを Cloud Storage にアップロードする関数
export async function uploadFile(
  filePath: string,
  destination: string
): Promise<void> {
  try {
    await storage.bucket().upload(filePath, {
      destination,
    });
    console.log(`File ${filePath} uploaded to ${destination} successfully.`);
  } catch (error) {
    console.error("Error uploading file:", error);
  }
}

// フォルダを再起的に探索して中身のファイルをアップロードする関数
export async function uploadDirectory(
  directoryPath: string,
  destination: string
): Promise<void> {
  const files = await getFilesInDirectory(directoryPath);
  for (const file of files) {
    const destinationPath = path.join(
      destination,
      path.relative(directoryPath, file)
    );
    console.log(`Uploading ${file} to ${destinationPath}`);
    uploadFile(file, destinationPath);
  }
}

// ファイルとフォルダの両方を受け付けてアップロードする関数
export async function upload(
  targetPath: string,
  destination: string
): Promise<void> {
  const stats = await stat(targetPath);
  if (stats.isDirectory()) {
    await uploadDirectory(targetPath, destination);
  } else if (stats.isFile()) {
    await uploadFile(targetPath, destination);
  } else {
    console.log(`${targetPath} is neither a file nor a directory.`);
  }
}

CLI 化

アップロードしかしないのでupload関数をラップするだけである。

scripts/typescript/storage-cli.ts
import { Command } from "commander";
import { upload } from "./firebase/storage";

const program = new Command();

program
  .command("upload <targetPath> <storagePath>")
  .description("Upload File to Cloud Storage")
  .action(async (targetPath: string, storagePath: string) => {
    await upload(targetPath, storagePath);
  });

program.parse(process.argv);

使ってみる

第一引数にローカルの実際のパスを指定し、第二引数には Cloud Storage 上のパスを指定して動作させる。

pnpm exec tsx scripts/typescript/storage-cli.ts upload ./files storage/items

閲覧機能がないので、Firebase コンソールからファイルが配置されているか確認する。

終わりに

一応これで Firestore と Cloud Storage を操作する CLI を用意することができた。この記事での実装では不十分に感じるかもしれないが、あとは各自の拡張で対応願いたい。

本記事の具体的なモチベーションについては別記事として書いたので、もし興味があれば以下の記事も見ていただけるとありがたい。
https://zenn.dev/huyu_kotori/articles/2024-12-12-0-make-website-scalable

脚注
  1. 使い方の参考記事 : https://zenn.dev/yamaden/articles/40e3c646b7df09 ↩︎

  2. 例ではosxになっているが、筆者環境が Mac だからであり利用環境で異なる。( https://code.visualstudio.com/docs/terminal/profiles↩︎

不遊小鳥

Discussion