Firebase Admin SDK を使って Firestore と Cloud Storage を CLI で操作してみる
はじめに
バックエンドのサーバーから 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 の利用自体が初めてであれば、まずは以下の拙著やネット上の入門記事等を参照願いたい。
目標
最終的には以下のように実行できる機能を実装する。
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
準備
基本的には以下の公式ガイドの通りに進めたが、いくつか使いやすいようにアレンジする。
サービスアカウント情報を取得
公式ガイドに従ってサービスアカウント用の秘密鍵ファイルを生成し取得する。
サービス アカウント用の秘密鍵ファイルを生成するには:
- Firebase コンソールで、[設定] > [サービス アカウント] を選択します。
- [新しい秘密鍵の生成] をクリックし、[キーを生成] をクリックして確定します。
- キーを含む JSON ファイルを安全に保管します。
作成してダウンロードしてきた JSON ファイルをわかりやすいようにserviceAccountKey.json
にリネームしておく。
次にリポジトリルートにsecrets
フォルダを作成し、serviceAccountKey.json
を作成したフォルダの中に入れる。
serviceAccountKey.json
はかなり機密情報(これでプロジェクトいじり放題)なため、git の管理下に置かないようにした方が良い。そのため.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
が発生してうまく動かなかった。
スクリプトの作成
ここから、Firebase Admin SDK を使って CLI ツールを作成していく。
SDK 初期化
公式のガイドによれば、
サービス アカウントを介して認可する場合、アプリケーションの認証情報を指定するには 2 つの選択肢があります。
GOOGLE_APPLICATION_CREDENTIALS
環境変数を設定することも、サービス アカウント キーへのパスをコード内で明示的に示すこともできます。 1 つ目の選択肢のほうが安全であるため、そちらを強くおすすめします。
とあるので、コードを書く前にGOOGLE_APPLICATION_CREDENTIALS
を設定する。
VS Code 限定だが、以下のように.vscode/settings.json
を作成してそこに書いておくと、VS Code上のターミナルのみでこの環境変数が自動設定されるので個人的にはオススメである。[2]
{
"terminal.integrated.env.osx": {
"GOOGLE_APPLICATION_CREDENTIALS": "${workspaceFolder}/secrets/serviceAccountKey.json",
}
}
準備ができたのでscripts/typescript/firebase
フォルダを作成し、admin.ts
ファイルに SDK を初期化するコードを書く。
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
は初期化済みのdb
とstorage
を提供する。
2つ目の選択肢による実装
ChatGPT に書いてもらった。
(serviceAccountKey.json
のファイルの場所がscripts/typescript/firebase/secrets/serviceAccountKey.json
であるとする。)
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 と接続する
複雑なクエリには対応せず、ドキュメントのget
とset
のみを実装する。
実際にやり取りするスクリプト
CLI の設計と切り離したget
とset
の処理をまずは実装する。
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
の方はとてもシンプルで、collection
とdocId
を指定すれば、ドキュメントの内容を取得して表示する。
setDocument
は少し複雑で、データがすでに Firestore にある場合とない場合で挙動が異なる。これは自動的にupdatedAt
とcreatedAt
を記録するためで、必要がないならもっとシンプルに書けると思われる。
CLI 化
次に作成した処理を CLI として利用するためのラッパーを書く。
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
関数だけであり、それ以外はフォルダを対象にするための実装である。
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
関数をラップするだけである。
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/yamaden/articles/40e3c646b7df09 ↩︎
-
例では
osx
になっているが、筆者環境が Mac だからであり利用環境で異なる。( https://code.visualstudio.com/docs/terminal/profiles ) ↩︎
Discussion