やまいも (@yarnaimo) です。
Firebase と JavaScript/TypeScript を使って Web サービスをいくつか作っているうちに得られた知見をメモ程度にまとめてみました。
W: がついているものは Web、A: は Admin です。
W: SSR 環境などでインスタンスが複数個作られないようにする
export const app = firebase.apps.length
? firebase.app()
: firebase.initializeApp(firebaseConfig)
🔥 Firestore
R: は firestore.rules
、I: は firestore.indexes.json
です
W: React Hooks でいい感じに使いたい
react-firebase-hooks を使うとこのように書けます ↓
type IPost = {
title: string
}
const Post = () => {
const [post, loading, error] = useDocumentData<IPost>(
db.collection('posts').doc('id')
)
return <div>{post.title}</div>
}
W&A: [TS] データに型を付けたい
a. データを取得・保存するときに変換したい場合 → withConverter
を使う (詳細はこちら)
b. データを取得・保存するときに変換しなくていい場合
type IPost = {
title: string
}
const postCollection = db.collection('posts') as firestore.CollectionReference<
IPost
>
const snapshot = await postCollection.doc('id').get()
snapshot.data().title // => string
A: ローカルエミュレータを使ったテストを書きたい
@firebase/testing
を使います
import {
clearFirestoreData,
firestore,
initializeAdminApp,
} from '@firebase/testing'
import * as admin from 'firebase-admin'
const projectId = 'project-id'
const app = initializeAdminApp({ projectId })
export const db = app.firestore()
afterEach(async () => {
await clearFirestoreData({ projectId })
})
afterAll(async () => {
await app.delete()
})
R: ユーザーが管理者かどうか判定する
admins/{管理者のuid}
に空のドキュメントを作っておくと isAdmin()
でそのユーザーが管理者かどうか判定できます
function adminPath() {
return /databases/$(database)/documents/admins/$(request.auth.uid)
}
function isAdmin() {
return exists(adminPath());
}
R: ユーザードキュメント以下でそのユーザーのみアクセスを許可する
match /users/{uid} {
function isCurrentUser() {
return request.auth.uid == uid;
}
match /posts/{postId} {
allow read, write: if isCurrentUser();
}
}
I: collectionGroup の単一フィールドのインデックスを作りたい
単一フィールドのインデックスは collection だとデフォルトで作成されるようになっていますが、collectionGroup だと作成されません。
fieldOverrides
を使うと collectionGroup で単一フィールドのインデックスを作ることができます。
posts
という collectionGroup で tags
フィールドの単一フィールドインデックスを作りたい場合:
{
"fieldOverrides": [
{
"collectionGroup": "posts",
"fieldPath": "tags",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION_GROUP"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION_GROUP"
}
]
}
]
}
🔥 Cloud Functions
W: 開発環境のブラウザからローカルエミュレータ上の関数を呼び出したい
// ↓ WebPack の EnvironmentPlugin などで環境変数を使えるようにしておく
const isDev = process.env.NODE_ENV !== 'production'
const functions = app.functions()
if (isDev) {
functions.useFunctionsEmulator(`http://localhost:5000`)
}
// 開発環境だとローカルエミュレータ上の関数が呼び出される
functions.httpsCallable('function-name')(data)
A: Pub/Sub 関数の実行日時のタイムゾーンを指定したい
日本時間の毎日 0:00 に実行したい場合
const timezone = 'Asia/Tokyo'
functions.pubsub
.schedule('0 0 * * *')
.timeZone(timezone)
.onRun(async () => {
// ...
})
A: 実行環境のタイムゾーンを指定したい
functions のエントリーポイントの最初にこれを書く
const timezone = 'Asia/Tokyo'
process.env.TZ = timezone
A: ローカルでも環境変数を使いたい
プロジェクトルートの .runtimeconfig.json
に環境変数を書いておくとローカルでも functions.config()
で取得できます
{
"a": {
"key": "value"
}
}
A: 一部のファイル・フォルダをデプロイから除外したい
デフォルトではおそらく node_modules
のみ除外されるようです
{
"functions": {
"source": ".",
"ignore": [
".git",
"node_modules",
"tmp"
]
}
}
W&A: [TS] Callable 関数の Request/Response に型を付けたい
Admin
1. functions.https.onCall
のラッパーを作る (type-fest を使用)
(認証ユーザーでない場合は unauthenticated
を返すようにしています)
import { https } from 'firebase-functions'
import { JsonObject, Merge, SetRequired } from 'type-fest'
type CallableData = {
request: JsonObject
response: JsonObject
}
export type Callable<T extends CallableData> = (
request: T['request'],
context: SetRequired<https.CallableContext, 'auth'>,
) => Promise<T['response']>
export type CallableDataType<
C extends Callable<any>,
T extends 'request' | 'response'
> = C extends Callable<infer Rs> ? Rs[T] : never
export const onCall = <T extends CallableData>(handler: Callable<T>) => {
const wrapped = async (
request: T['request'],
context: https.CallableContext,
): Promise<T['response']> => {
if (!context.auth) {
throw new https.HttpsError('unauthenticated', '認証が必要です')
}
const response = await handler(request, {
...context,
auth: context.auth,
})
return response
}
const runnable = https.onCall(wrapped)
return runnable as Merge<typeof runnable, { run: typeof handler }>
}
2. ↑ のラッパーを使って Callable 関数を記述する
import { onCall } from '../onCall'
export type CFSaveMessage = {
request: {
text: string
}
response: {
result: boolean
}
}
export const saveMessage = onCall<CFSaveMessage>(
async (requestData, context) => {
context.auth.uid // string
requestData.text // string
// ...
return { result: true }
},
)
3. Callable 関数を 1 つのオブジェクトにまとめる
import { saveMessage } from './saveMessage'
// ...
export const callable = {
saveMessage,
// ...
}
4 . functions のエントリーポイントで ↑ の callable
を import & export する
import { callable } from './path-to-callable'
export { callable }
こうすると callable-saveMessage
のように Callble 関数の名前に prefix が付きます。
Web
1. 呼び出し用のラッパーを作る
import { callable } from './callable'
import { CallableDataType } from './onCall'
const functions = app.functions()
type Callables = typeof callable
export const call = async <
N extends keyof Callables,
C extends Callables[N] = Callables[N]
>(
name: N,
requestData: CallableDataType<C['run'], 'request'>,
) => {
const result = await functions
.httpsCallable(`callable-${name}`)({
...requestData,
})
.catch((error) => console.error(error))
return result && (result.data as CallableDataType<C['run'], 'response'>)
}
2. 実際に呼び出してみる
const res = await call('saveMessage', { text: 'test' })
call
の第 1 引数に指定した関数名に応じて Request/Response にも自動的に型が付きます。
🔥 Authentication
W: パスワードの再設定メールの言語をブラウザの言語に合わせる
export const auth = app.auth()
auth.useDeviceLanguage()
🔥 Storage
A: 保存したファイルを Public にして URL を取得したい
PNG 画像を保存する例:
export const savePNGImage = async (path: string, buffer: Buffer) => {
const file = bucketAdmin.file(path)
await file.save(buffer, { contentType: 'image/png' })
await file.makePublic()
const url = `https://storage.googleapis.com/${bucketAdmin.name}/${
file.name
}`
return url
}