Help us understand the problem. What is going on with this article?

[TS] Firestoreを型安全にするライブラリ Fireschema を作った [rules自動生成/データ自動型付け]

  • 8/18 更新
    • Custom Transformer を使用し、documentSchema<T>() の型引数 (T の部分) の AST 解析によって rules の文字列が自動生成できるようになったため、documentSchema() の引数が不要になりました

スキーマレスな Firestore では、その気になれば一般ユーザーが予期しない構造のデータをセットできてしまうので、セキュリティルールで書き込み時のスキーマ検証を設定する必要があります。しかし、セキュリティルールはモデル定義に合わせて手動で書かなければならないのでミスが起きやすく、そのためにテストを書くのもコストに見合わない感があります。

例: User 型のモデルとそのスキーマ検証を行う関数

type User = {
    name: string
    displayName: string | null
    age: number
    createdAt: firestore.FieldValue.Timestamp
}
function validateUser(data) {
  return data.name is string
    && (data.displayName is string || data.displayName is null)
    && data.age is int
    && data.createdAt is timestamp;
}

また、TypeScript を使った開発環境においては、ドキュメントデータを自動で型付けすることができないので、誤ったキャストやコレクションパスの typo などの可能性があり、これらもコンパイル時に検出するのが理想的です。

Fireschema は、コレクション構造・スキーマ・アクセス制御などをオブジェクト形式で定義するだけで、firestore.rules の生成ドキュメントの型付けなどを自動で行い、これらの問題を解決します。

ReadMe Card

インストール

Fireschema は Variadic Tuple Types を使用しているため TypeScript 4.0 以上 のみサポートしています。(TypeScript 4.0 は現在 β 版です)

yarn add fireschema
yarn add -D typescript@^4.0.0-beta ts-node

Setup

Custom Compiler / Transformer

Fireschema では TypeScript の AST から型情報を取得する目的で Custom Transformer を使用するため、ビルド時は ttypescript という Custom Compiler を使う必要があります。

Custom Compiler / Transformer を使用するには、設定ファイルに以下の内容を追加してください。

package.json

ttsc / ts-node は環境変数 TS_NODE_PROJECT を使うと任意の tsconfig.json が指定できます。

{
    "scripts": {
        "build": "ttsc", // <- tsc
        "ts-node": "ts-node --compiler ttypescript" // <- ts-node
    }
}

tsconfig.json

{
    "compilerOptions": {
        "plugins": [
            {
                "transform": "fireschema/transformer"
            }
        ]
    }
}

jest.config.js

module.exports = {
    globals: {
        'ts-jest': {
            tsConfig: 'tsconfig.json',
            compiler: 'ttypescript',
        },
    },
}

Override Dependencies

fireschema が依存する一部のパッケージは TypeScript 3.9 に依存しているため、Selective dependency resolutions で依存関係を上書きする必要があります。(yarn のみ対応)

{
    "resolutions": {
        "fireschema/**/typescript": "^4.0.0-beta"
    }
}

使い方

注意事項

  • fireschema は変数名に応じてコードを変換するため、fireschema からのインポート以外で以下の変数名を使用しないでください
    • $documentSchema
    • $collectionAdapter
    • __$__

想定ケース

  • /users/{uid}
    • ユーザー (User)
  • /users/{uid}/posts/{postId}
    • ユーザーの投稿 (PostA または PostB)

1. スキーマ定義

Fireschema では、まず各モデルの documentSchemacollectionAdapter を定義してから、それを使って DB 全体のコレクション構造を定義します。

$documentSchema<T>()

firestore.rules に出力されるスキーマを指定します。この関数の呼び出しコードは、Custom Transformer によって型引数 (T の部分) が AST 解析された- 8/18 更新 - TypeScript 黒魔術に手を染めました Custom Transformer を使用し、documentSchema<T>() の型引数 (T の部分) の AST 解析によって rules の文字列が自動生成できるようになったため、documentSchema() の引数が不要になりました

スキーマレスな Firestore では、その気になれば一般ユーザーが予期しない構造のデータをセットできてしまうので、セキュリティルールで書き込み時のスキーマ検証を設定する必要があります。しかし、セキュリティルールはモデル定義に合わせて手動で書かなければならないのでミスが起きやすく、そのためにテストを書くのもコストに見合わない感があります。

例: User 型のモデルとそのスキーマ検証を行う関数

type User = {
    name: string
    displayName: string | null
    age: number
    createdAt: firestore.FieldValue.Timestamp
}
function validateUser(data) {
  return data.name is string
    && (data.displayName is string || data.displayName is null)
    && data.age is int
    && data.createdAt is timestamp;
}

また、TypeScript を使った開発環境においては、ドキュメントデータを自動で型付けすることができないので、誤ったキャストやコレクションパスの typo などの可能性があり、これらもコンパイル時に検出するのが理想的です。

Fireschema は、コレクション構造・スキーマ・アクセス制御などをオブジェクト形式で定義するだけで、firestore.rules の生成ドキュメントの型付けなどを自動で行い、これらの問題を解決します。

ReadMe Card

インストール

Fireschema は Variadic Tuple Types を使用しているため TypeScript 4.0 以上 のみサポートしています。(TypeScript 4.0 は現在 β 版です)

yarn add fireschema
yarn add -D typescript@^4.0.0-beta ts-node

Setup

Custom Compiler / Transformer

Fireschema では TypeScript の AST から型情報を取得する目的で Custom Transformer を使用するため、ビルド時は ttypescript という Custom Compiler を使う必要があります。

Custom Compiler / Transformer を使用するには、設定ファイルに以下の内容を追加してください。

package.json

ttsc / ts-node は環境変数 TS_NODE_PROJECT を使うと任意の tsconfig.json が指定できます。

{
    "scripts": {
        "build": "ttsc", // <- tsc
        "ts-node": "ts-node --compiler ttypescript" // <- ts-node
    }
}

tsconfig.json

{
    "compilerOptions": {
        "plugins": [
            {
                "transform": "fireschema/transformer"
            }
        ]
    }
}

jest.config.js

module.exports = {
    globals: {
        'ts-jest': {
            tsConfig: 'tsconfig.json',
            compiler: 'ttypescript',
        },
    },
}

Override Dependencies

fireschema が依存する一部のパッケージは TypeScript 3.9 に依存しているため、Selective dependency resolutions で依存関係を上書きする必要があります。(yarn のみ対応)

{
    "resolutions": {
        "fireschema/**/typescript": "^4.0.0-beta"
    }
}

使い方

注意事項

  • fireschema は変数名に応じてコードを変換するため、fireschema からのインポート以外で以下の変数名を使用しないでください
    • $documentSchema
    • $collectionAdapter
    • __$__

想定ケース

  • /users/{uid}
    • ユーザー (User)
  • /users/{uid}/posts/{postId}
    • ユーザーの投稿 (PostA または PostB)

1. スキーマ定義

Fireschema では、まず各モデルの documentSchemacollectionAdapter を定義してから、それを使って DB 全体のコレクション構造を定義します。

$documentSchema<T>()

firestore.rules に出力されるスキーマを指定します。この関数の呼び出しコードは、Custom Transformer によって型引数 (T の部分) が AST 解析された上で、rules の文字列に変換されます。

$collectionAdapter<T>()({ selectors: (q) => <query functions> })

データ取得時に使用するクエリを定義します。(使い方は PostAdapter の例を参照)

import {
    $adapter,
    $allow,
    $collectionGroups,
    $docLabel,
    $documentSchema,
    $functions,
    $or,
    $schema,
    $collectionAdapter,
    createFirestoreSchema,
    FTypes,
} from '..'

// user
type User = {
    name: string
    displayName: string | null
    age: number
    timestamp: FTypes.Timestamp
    options: { a: boolean }
}
const UserSchema = $documentSchema<User>()
const UserAdapter = $collectionAdapter<User>()({})

// post
type PostA = {
    type: 'a'
    tags: { id: number; name: string }[]
    text: string
}
type PostB = {
    type: 'b'
    tags: { id: number; name: string }[]
    texts: string[]
}
const PostSchema = $documentSchema<PostA | PostB>()
const PostAdapter = $collectionAdapter<PostA | PostB>()({
    selectors: (q) => ({
        byTag: (tag: string) => q.where('tags', 'array-contains', tag),
    }),
})

createFirestoreSchema

DB 全体のコレクション構造を定義します。ネストされたコレクション構造をそのままオブジェクトで書けるので、視覚的にも分かりやすくなっています。

$docLabel$schema など、コレクションのメタデータを指定する key は string ではなく Symbol なので、内部で処理するときに同じ階層に混在する child collection と区別しています。

スキーマ定義は firestoreSchema として named export してください。

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]: {
        users: {
            [$docLabel]: 'uid',
            [$schema]: UserSchema,
            [$adapter]: UserAdapter,
            [$allow]: {
                read: true,
            },
        },
    },

    // /users/{uid}
    users: {
        [$docLabel]: 'uid', // {uid} の部分
        [$schema]: UserSchema, // documentSchema
        [$adapter]: UserAdapter, // collectionAdapter
        [$allow]: {
            // アクセス制御
            read: true, // 誰でも可
            write: $or(['matchesUser(uid)', 'isAdmin()']), // {uid} と一致するユーザー or 管理者のみ可
        },

        // /users/{uid}/posts/{postId}
        posts: {
            [$docLabel]: 'postId',
            [$schema]: PostSchema,
            [$adapter]: PostAdapter,
            [$allow]: {
                read: true,
                write: $or(['matchesUser(uid)']), // {uid} と一致するユーザーのみ可
            },
        },
    },
})

2. firestore.rules の生成

yarn fireschema <スキーマのパス>.ts を実行すると firestore.rules が生成されます。ttsc / ts-node と同じく、環境変数 TS_NODE_PROJECT で任意の tsconfig.json が指定できます。

生成される firestore.rules の例

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function isAdmin() {
      return exists(/databases/$(database)/documents/admins/$(request.auth.uid));
    }

    function matchesUser(uid) {
      return request.auth.uid == uid;
    }

    match /{path=**}/users/{uid} {
      allow read: if true;
    }

    match /users/{uid} {
      function __validator_0__(data) {
        return (
          data.name is string
            && ((data.displayName == null || !(displayName in data)) || data.displayName is string)
            && (data.age is int || data.age is float)
            && data.timestamp is timestamp
            && 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 ((
            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
          ) || (
            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. コレクション・ドキュメントの操作

コントローラの初期化

先ほど定義したスキーマを使ってコントローラを初期化します。

Fireschema は web と admin 両方に対応しています。DocumentReference の set() の戻り値などは web と admin で型が異なりますが、initFirestore() の引数に応じて自動的に正しい型が付くようになっています。

import firebase, { firestore, initializeApp } from 'firebase' // または firebase-admin
import { schema } from '<スキーマファイルのパス>'

const app: firebase.app.App = initializeApp({
    // ...
})
const firestoreApp = app.firestore()

const $store: FirestoreController<
    typeof firestoreApp,
    typeof schema
> = initFirestore(firestore, firestoreApp, schema)

コレクションの参照・データ取得

コレクションの参照は $store.collection(parent, name) のような形で、parent は 'root'親ドキュメントを指定します。name は parent に応じて指定可能な値が変わるようになっています。

例えば上記のスキーマでは、

  • parent が 'root' → スキーマの root には users しかないので name には 'users' のみ指定可能
  • parent が user → スキーマの user 内には posts しかないので 'posts' のみ指定可能

という感じです。存在しないコレクション名を指定するとエラーが出るので安全です。

const users = $store.collection('root', 'users') // /users
const user = users.ref.doc('userId') // /users/userId

const posts = $store.collection(user, 'posts') // /users/userId/posts
const post = posts.ref.doc('123') // /users/userId/posts/123

const postSnapshot = await post.get() // DocumentSnapshot<PostA | PostB>

スキーマで定義したとおり、postDataPostA | PostB という型になります。

コレクションやクエリの取得はこのように行います。collectionAdapter で定義したクエリは、select プロパティから使用できます。

const postsSnapshot = await posts.ref.get()
const techPostsSnapshot = await posts.select.byTag('tech').get()

parentOfCollection() を使うと、コレクションの親ドキュメントが型付きで取得できます。

const user = $store.parentOfCollection(posts.ref) // DocumentReference<User>

コレクショングループの参照・データ取得

コレクショングループの取得では、['users', 'posts'] のように、コレクションのスキーマ上のパスを指定します。(「users と posts」という意味ではなく、「users 内の posts」という意味です)

const postsGroup = $store.collectionGroup(['users', 'posts'])
const techPostsSnapshot = await postsGroup.select.byTag('tech').get()

ここで注意が必要なのは、生の SDK での実際の collectionGroup の参照は collectionGroup('posts') になるということです。もし、/users/{uid}/posts/{postId} 以外の場所にも posts コレクションがあって (例えば /posts/{postId} など)、そのドキュメントデータの型が異なる場合、TypeScript の型情報と一致しないドキュメントも参照してしまうことになります。

これを防ぐには、場所ごとにコレクションの名前を unique にする必要があります。(例えば /posts/{postId}/sharedPosts/{postId} にするなど)

ドキュメントの作成・更新

ドキュメントの作成は create() で行います。スキーマで指定した型、または FieldValue のみ指定できます。set() のラッパーなので、すでにドキュメントが存在してもエラーにならない点に注意してください。

await $store.create(user, {
    name: 'umi',
    displayName: null,
    age: 16,
    timestamp: $store.FieldValue.serverTimestamp(),
}

ドキュメントの更新は setMerge()update() で行います。それぞれ set(data, { merge: true })update(data) のラッパーで、データの型が Partial になっている点以外は create() と同じです。

トランザクション処理には runTransaction を使います。

await $store.runTransaction(async (tc) => {
    const snapshot = await tc.get(user)
    tc.setMerge(user, {
        age: snapshot.data()!.age + 1,
    })
})

内部実装について

Custom Transformer

Custom Transformer では TypeScript の Compiler API と、そのラッパーである ts-morph を使って型情報の AST 解析を行い、rules の文字列に変換しています。

https://github.com/yarnaimo/fireschema/blob/master/src/_transformer/main.ts
https://github.com/yarnaimo/fireschema/blob/master/src/_transformer/document-schema.ts

データの型付け

Fireschema では TypeScript の型システムを最大限活用して強力な型付け機能を提供しています。その中心となっているのがネストしたオブジェクトの特定のプロパティの型を再帰的に取得する utility です。

type Next<U> = U extends readonly [string, ...string[]]
  ? ((...args: U) => void) extends (top: any, ...args: infer T) => void
    ? T
    : never
  : never

export type GetDeep<T, L extends readonly string[]> = L[0] extends keyof T
  ? {
      0: T[L[0]]
      1: GetDeep<T[L[0]], Next<L>>
    }[L[1] extends undefined ? 0 : 1]
  : L extends []
  ? T
  : never

詳細は省きますが、オブジェクトのプロパティのパスを配列で指定して型を取得できるようになっています。

type Foo = {
    a: {
        b: {
            c: number
        }
    }
}
type _ = GetDeep<Foo, ['a', 'b', 'c']> // Foo['a']['b']['c'] と同じ (number)

FirestoreController 経由で取得したドキュメントの参照には、データ自体の型に加えて __loc__ というスキーマ上のパスを表すプロパティが型定義上でのみ設定されます (実際の値はセットしない)。

例えば users コレクションのドキュメントは DocumentReference<User & { __loc__: ['users'] }> という型になり、GetDeep<typeof schema, ['users']> のような形でスキーマから情報を取得します。

そして、その child である posts コレクションのドキュメントのスキーマ上のパスは、親のスキーマ上のパスとコレクション名を Variadic Tuple Types を使って結合した ['users', 'posts'] になるので、データの型は DocumentReference<(PostA | PostB) & { __loc__: ['users', 'posts'] }> のようになります。

さらに posts の child collection が存在した場合も、posts のスキーマ上のパス['users', 'posts'] と子のコレクション名を結合した ['users', 'posts', 'collectionName'] …… というように、ネストしたコレクションでもスキーマ情報が取得できるようになっています。

type ParentLoc = ['users']
type Name = 'posts'
type Loc = [...ParentLoc, Name] // => ['users', 'posts']

今後の予定

  • [x] documentSchema() でオブジェクトの子プロパティのスキーマも指定できるようにする (現在は 'map' しか指定できないので子プロパティのスキーマ検証ができない)
  • [ ] 数値の範囲など、任意のセキュリティルールを設定できるようにする
  • [ ] firestore.indexes.json を自動生成する
  • [x] トランザクション処理をわかりやすくする

まとめ

ここまで「Firestore を型安全にするライブラリ」として紹介してきた Fireschema ですが、実は Cloud Functions の Callable Function のリクエスト・レスポンスや PubSub Topic などを型安全にする機能も持っています。使い方などはこちらをご覧ください ↓

https://github.com/yarnaimo/fireschema/blob/master/src/__tests__/_fixtures/functions-schema.ts
https://github.com/yarnaimo/fireschema/blob/master/src/__tests__/_infrastructure/functions-server.ts

Fireschema を使うと、Firestore での開発を効率よく安全に行うことができます。興味のある方はぜひ使ってみてください。

特に Firestore の型付けの方は型定義が複雑すぎるなど改善できる点もありそうなので、皆さまのコントリビューションもお待ちしています。

https://github.com/yarnaimo/fireschema

Twitter: @yarnaimo

yarnaimo
Webサービスの開発などを行っています。 TypeScript / Firebase / React / Next.js / Node.js / UI Design
https://yarnaimo.vercel.app
admin-guild
「Webサービスの運営に必要なあらゆる知見」を共有できる場として作られた、運営者のためのコミュニティです。
https://admin-guild.slack.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした