型安全なFirestoreフレームワーク「Fireschema」の紹介
Firestore の JavaScript SDK では、withConverter()
を使うと任意のコレクションに型をつけたり自動でデータをデコード/エンコードしたりできますが、コレクションの親子関係の情報は持たないので、SubCollection や Parent を参照するときは毎回手動で withConverter を呼び出さなければなりません。
また、Firestore のセキュリティルールで書き込み時に型チェックをするには手動でルールを記述する必要があり、構文も複雑になりやすいので、できれば TypeScript の型情報から自動でルールを生成したいところです。
Fireschema を使うと、スキーマを宣言的に記述するだけで、親子関係を考慮したデータの型付け、デコード、セキュリティルールの生成などを自動で行うことができます。
Fireschema の機能
Firestore
- Web も Admin も共通のインターフェイスで操作可能
- データの型付け・デコード
- コード生成ではなく、型システム上で完結
- SubCollection や Parent も自動で型付け
- 書き込み時の
FieldValue
はキャスト不要
- データの読み取り・書き込み
- 取得したドキュメントデータに自動で
id
フィールドを追加する - ドキュメントの書き込み時に
_createdAt
_updatedAt
フィールドを自動で更新する
- 取得したドキュメントデータに自動で
- セキュリティルール生成
- TypeScript の型情報から型バリデーションルールを生成 (TypeScript Compiler API を使用)
- アクセス制御をオブジェクト形式で記述可能
- データをリアルタイムで取得する React Hooks (react-firebase-hooksを使用)
Cloud Functions
- Callable Function のスキーマを定義し、リクエスト・レスポンスを自動で型付け / 実行時に自動でバリデーション
- Firestore Trigger の Snapshot データをパス文字列に応じて自動で型付け
📌 Setup
Requirement
- TypeScript (>= 4.2)
Install
yarn add fireschema
yarn add -D typescript ts-node
Custom Transformer
Firestore のセキュリティルール生成や Callable Function へのバリデーションコード埋め込みを行うには、TypeScript の AST から型情報を取得するために Fireschema の Custom transformer 経由で.ts ファイルをコンパイルする必要があります。
現時点では公式の TypeScript パッケージは Custom transformer をサポートしていないため、TypeScript コンパイラをラップする ttypescript というツールを使う必要があります。
ttypescript を使って Fireschema の Custom transformer 経由でコンパイルするには、tsconfig.json
にこちらのオプションを追加し、
{
"compilerOptions": {
"plugins": [
{
"transform": "fireschema/transformer"
}
]
}
}
コマンドを以下のように置き換えてください。
before | after | |
---|---|---|
typescript | tsc |
ttsc (ttypescript のコンパイルコマンド) |
ts-node | ts-node |
ts-node --compiler ttypescript |
ttsc
とts-node
は環境変数TS_NODE_PROJECT
でtsconfig.json
のパスを指定できます。
ts-jest を使用している場合は jest config にこちらを追加してください。
module.exports = {
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json',
compiler: 'ttypescript',
},
},
}
📌 使用例 - Firestore
データ構造
-
users/{uid}
-User
-
users/{uid}/posts/{postId}
-PostA | PostB
1. スキーマ定義
スキーマ定義は firestoreSchema
として named export する必要があります。
import { Merge } from 'type-fest'
import {
$allow,
$collectionGroups,
$collectionSchema,
$docLabel,
$functions,
$or,
$schema,
createFirestoreSchema,
FTypes,
} from 'fireschema'
export type User = {
name: string
displayName: string | null
age: number
timestamp: FTypes.Timestamp
options: { a: boolean } | undefined
}
export type UserDecoded = Merge<User, { timestamp: Date }>
const UserSchema = $collectionSchema<User, UserDecoded>()({
decoder: (data) => ({
...data,
timestamp: data.timestamp.toDate(),
}),
})
type PostA = {
type: 'a'
tags: { id: number; name: string }[]
text: string
}
type PostB = {
type: 'b'
tags: { id: number; name: string }[]
texts: string[]
}
const PostSchema = $collectionSchema<PostA | PostB>()({
selectors: (q) => ({
byTag: (tag: string) => q.where('tags', 'array-contains', tag),
}),
})
export const firestoreSchema = createFirestoreSchema({
[$functions]: {
// /admins/<uid> が存在するかどうか
['isAdmin()']: `
return exists(/databases/$(database)/documents/admins/$(request.auth.uid));
`,
// アクセスしようとするユーザーの uid が {uid} と一致するかどうか
['matchesUser(uid)']: `
return request.auth.uid == uid;
`,
},
[$collectionGroups]: {
posts: {
[$docLabel]: 'postId',
[$schema]: PostSchema,
[$allow]: {
read: true,
},
},
},
// /users/{uid}
users: {
[$docLabel]: 'uid', // {uid}
[$schema]: UserSchema,
[$allow]: {
// アクセス制御
read: true, // 誰でも可
write: $or(['matchesUser(uid)', 'isAdmin()']), // {uid} と一致するユーザー or 管理者のみ可
},
// /users/{uid}/posts/{postId}
posts: {
[$docLabel]: 'postId',
[$schema]: PostSchema,
[$allow]: {
read: true,
write: 'matchesUser(uid)',
},
},
},
})
2. firestore.rules の自動生成
以下のコマンドを実行すると、型バリデーションルールやアクセス制御情報を含む firestore.rules がスキーマから生成できます。
yarn fireschema <path-to-schema>.ts
ttsc
/ts-node
と同じく環境変数TS_NODE_PROJECT
でtsconfig.json
のパスを指定できます。
生成される firestore.rules の例 (一部抜粋)
match /users/{uid} {
function __validator_0__(data) {
return (
(!("_createdAt" in data) || data._createdAt == request.time)
&& (!("_updatedAt" in data) || data._updatedAt == request.time)
&& data.name is string
&& (data.displayName == null || data.displayName is string)
&& (data.age is int || data.age is float)
&& data.timestamp is timestamp
&& (!("options" in data) || data.options.a is bool)
);
}
allow read: if true;
allow write: if ((matchesUser(uid) || isAdmin()) && __validator_0__(request.resource.data));
match /posts/{postId} {
function __validator_1__(data) {
return ((
(!("_createdAt" in data) || data._createdAt == request.time)
&& (!("_updatedAt" in data) || data._updatedAt == request.time)
&& data.type == "a"
&& (data.tags.size() == 0 || ((data.tags[0].id is int || data.tags[0].id is float) && data.tags[0].name is string))
&& data.text is string
) || (
(!("_createdAt" in data) || data._createdAt == request.time)
&& (!("_updatedAt" in data) || data._updatedAt == request.time)
&& data.type == "b"
&& (data.tags.size() == 0 || ((data.tags[0].id is int || data.tags[0].id is float) && data.tags[0].name is string))
&& (data.texts.size() == 0 || data.texts[0] is string)
));
}
allow read: if true;
allow write: if (matchesUser(uid) && __validator_1__(request.resource.data));
}
}
3. コレクション・ドキュメントの操作
コレクション・ドキュメントの操作には TypedFirestore
クラスを使用します。
Web と Admin 両方に対応しており、コンストラクタにどちらかの firestore インスタンスを渡して初期化します。
TypedFirestore
から参照したコレクションやドキュメントは、元の CollectionReference
や DocumentReference
をラップした、TypedCollectionRef
や TypedDocumentRef
というクラスになっています。
import firebase from 'firebase/app' // または firebase-admin
import { TypedFirestore } from 'fireschema'
import { firestoreSchema } from './1-1-schema'
const app: firebase.app.App = firebase.initializeApp({
// ...
})
const firestoreApp = app.firestore()
firestoreApp.settings({ ignoreUndefinedProperties: true })
/**
* TypedFirestore を初期化
*/
export const typedFirestore: TypedFirestore<
typeof firestoreSchema,
firebase.firestore.Firestore
> = new TypedFirestore(firestoreSchema, firebase.firestore, firestoreApp)
/**
* コレクション・ドキュメントを参照 / スナップショット取得
* (存在しないコレクション名を指定するとコンパイルエラー)
*/
const users = typedFirestore.collection('users') // TypedCollectionRef インスタンス
const user = users.doc('userId') // TypedDocumentRef インスタンス
const posts = user.collection('posts')
const post = posts.doc('123')
const techPosts = user.collectionQuery(
'posts',
(q) => q.byTag('tech') // スキーマで定義した selector
)
!(async () => {
await user.get() // DocumentSnapshot<User>
await post.get() // DocumentSnapshot<PostA | PostB>
await posts.get() // QuerySnapshot<PostA | PostB>
await techPosts.get() // QuerySnapshot<PostA | PostB>
})
/**
* 取得したスナップショットのSubcollectionを取得
*/
!(async () => {
const snap = await users.get()
const firstUserRef = snap.docs[0]!.ref
await typedFirestore
.wrapDocument(firstUserRef)
.collection('posts')
.get()
})
/**
* 親コレクション・ドキュメントを参照
*/
const _posts = post.parentCollection()
const _user = posts.parentDocument()
/**
* コレクショングループを参照 / スナップショット取得
*/
const postsGroup = typedFirestore.collectionGroup(
'posts', // SDK の collectionGroup() に渡されるコレクション名
'users.posts' // スキーマオプションの取得用
)
const techPostsGroup = typedFirestore.collectionGroupQuery(
'posts',
'users.posts',
(q) => q.byTag('tech')
)
!(async () => {
await postsGroup.get() // QuerySnapshot<PostA | PostB>
await techPostsGroup.get() // QuerySnapshot<PostA | PostB>
})
/**
* データの書き込み
*/
!(async () => {
await user.create({
name: 'test',
displayName: 'Test',
age: 20,
timestamp: typedFirestore.firestoreStatic.FieldValue.serverTimestamp(),
options: { a: true },
})
await user.setMerge({
age: 21,
})
await user.update({
age: 21,
})
await user.delete()
})
/**
* トランザクション
*/
!(async () => {
await typedFirestore.runTransaction(async (tt) => {
const snap = await tt.get(user)
tt.update(user, {
age: snap.data()!.age + 1,
})
})
})
4. React Hooks
import React from 'react'
import { useTypedDocument, useTypedQuery } from 'fireschema/hooks'
import { typedFirestore } from './1-3-typed-firestore'
/**
* コレクション・クエリをリアルタイムで取得
*/
export const UsersComponent = () => {
const users = useTypedQuery(typedFirestore.collection('users'))
if (!users.data) {
return <span>{'Loading...'}</span>
}
return (
<ul>
{users.data.map((user, i) => (
<li key={i}>{user.displayName}</li>
))}
</ul>
)
}
/**
* ドキュメントをリアルタイムで取得
*/
export const UserComponent = ({ id }: { id: string }) => {
const user = useTypedDocument(typedFirestore.collection('users').doc(id))
if (!user.data) {
return <span>{'Loading...'}</span>
}
return <span>{user.data.displayName}</span>
}
📌 使用例 - Cloud Functions
1. Functions を作成
Cloud Functions の function の作成には TypedFunctions
クラスを使用します。
Function を記述する functions/index.ts などでは、各タイプの function をそれぞれ callable
firestoreTrigger
http
topic
schedule
としてオブジェクトにまとめて named export する必要があります。
HTTPS callable function は、指定したスキーマの型情報に基づいてビルド時に Custom transformer がバリデーションコードを埋め込み、リクエスト時に自動でバリデーションが行われます。
import { firestore } from 'firebase-admin'
import * as functions from 'firebase-functions'
import { Merge } from 'type-fest'
import { $jsonSchema, TypedFunctions } from 'fireschema'
import { firestoreSchema, User } from './1-1-schema'
/**
* TypedFunctions を初期化
*/
const timezone = 'Asia/Tokyo'
const typedFunctions = new TypedFunctions(
firestoreSchema,
firestore,
functions,
timezone
)
const builder = functions.region('asia-northeast1')
export type UserJson = Merge<User, { timestamp: string }>
export const callable = {
createUser: typedFunctions.callable({
schema: [
$jsonSchema<UserJson>(), // リクエストデータのスキーマ (リクエスト時に自動バリデーションされる)
$jsonSchema<{ result: boolean }>(), // レスポンスデータのスキーマ
],
builder,
handler: async (data, context) => {
console.log(data) // UserJson
return { result: true }
},
}),
}
export const firestoreTrigger = {
onUserCreate: typedFunctions.firestoreTrigger.onCreate({
builder,
path: 'users/{uid}',
handler: async (decodedData, snap, context) => {
console.log(decodedData) // UserDecoded (パス文字列から自動で型付け)
console.log(snap) // QueryDocumentSnapshot<User>
},
}),
}
export const http = {
getKeys: typedFunctions.http({
builder,
handler: (req, resp) => {
if (req.method !== 'POST') {
resp.status(400).send()
return
}
resp.json(Object.keys(req.body))
},
}),
}
export const topic = {
publishMessage: typedFunctions.topic('publish_message', {
schema: $jsonSchema<{ text: string }>(),
builder,
handler: async (data) => {
data // { text: string }
},
}),
}
export const schedule = {
cron: typedFunctions.schedule({
builder,
schedule: '0 0 * * *',
handler: async (context) => {
console.log(context.timestamp)
},
}),
}
2. HTTPS callable function を呼び出す
Function モジュール (functions/index.ts など) の型に基づき、HTTPS callable function のリクエスト・レスポンスが型でガードされます。
import firebase from 'firebase/app'
import React from 'react'
import { TypedCaller } from 'fireschema'
type FunctionsModule = typeof import('./2-1-typed-functions')
const app: firebase.app.App = firebase.initializeApp({
// ...
})
const functionsApp = app.functions('asia-northeast1')
export const typedCaller = new TypedCaller<FunctionsModule>(functionsApp)
const Component = () => {
const createUser = async () => {
const result = await typedCaller.call('createUser', {
name: 'test',
displayName: 'Test',
age: 20,
timestamp: new Date().toISOString(),
options: { a: true },
})
if (result.error) {
console.error(result.error)
return
}
console.log(result.data)
}
return <button onClick={createUser} />
}
まとめ
@react-native-firebase/firestore に withConverter() が実装されたら React Native にも対応する予定です。
最後まで読んでいただきありがとうございました。
質問や Pull Request などもお待ちしています!
(気に入ったらぜひ Star ください 🙏)
Discussion