🧇

型安全なFirestoreフレームワーク「Fireschema」の紹介

14 min read

以前こちらの Qiita 記事で紹介したライブラリの新バージョン (v4) 公開に伴うリライト版です
https://qiita.com/yarnaimo/items/e7381ea9229d55320ae8

Firestore の JavaScript SDK では、withConverter() を使うと任意のコレクションに型をつけたり自動でデータをデコード/エンコードしたりできますが、コレクションの親子関係の情報は持たないので、SubCollection や Parent を参照するときは毎回手動で withConverter を呼び出さなければなりません。

また、Firestore のセキュリティルールで書き込み時に型チェックをするには手動でルールを記述する必要があり、構文も複雑になりやすいので、できれば TypeScript の型情報から自動でルールを生成したいところです。

Fireschema を使うと、スキーマを宣言的に記述するだけで、親子関係を考慮したデータの型付け、デコード、セキュリティルールの生成などを自動で行うことができます。

https://github.com/yarnaimo/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

ttscts-node は環境変数 TS_NODE_PROJECTtsconfig.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_PROJECTtsconfig.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 から参照したコレクションやドキュメントは、元の CollectionReferenceDocumentReference をラップした、TypedCollectionRefTypedDocumentRef というクラスになっています。

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 などもお待ちしています!

https://github.com/yarnaimo/fireschema

(気に入ったらぜひ Star ください 🙏)

この記事に贈られたバッジ

Discussion

ログインするとコメントできます