Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/yarnaimo/fireschema

Strongly typed Firestore framework for TypeScript
https://github.com/yarnaimo/fireschema

cloud-functions firebase firestore firestore-rules react react-hooks ttypescript typescript

Last synced: 7 days ago
JSON representation

Strongly typed Firestore framework for TypeScript

Awesome Lists containing this project

README

        


![Fireschema](./logo.png)

Strongly typed Firestore framework for TypeScript


## Features

- **Strong type safety for Firestore** - Automatically provide type information to _nested documents_ without unsafe type assertions, from the simple schema. Also support data decoding.
- **Security rules generation** - Generate firestore.rules file including data type validation and access control from the schema.
- **React Hooks** - Get realtime updates with React Hooks.
- **Type safety for Cloud Functions**
- Automatically provide type information to snapshot data on Firestore Trigger Function based on the path string.
- Guard HTTPS callable function's request/response data type _both on compile time and runtime_.


## Requirement

- **TypeScript** (>= 4.4)


## Install

```sh
yarn add fireschema firebase firebase-admin firebase-functions zod
yarn add -D typescript ts-node
```


## Setup

> 🎉 Since Fireschema v5, you no longer need to compile codes via custom transformer.


## Usage - Firestore

### Schema Transformation

| Zod Schema | Security Rules Output |
| ------------------------------------ | ---------------------------------------------------------------------- |
| `z.any()` | `true` |
| `z.unknown()` | `true` |
| `z.undefined()` | `!("key" in data)` |
| `z.null()` | `data.key == null` |
| `z.boolean()` | `data.key is bool` |
| `z.literal('a')` | `data.key == "a"` |
| `z.string()` | `data.key is string` |
| `z.string().min(5)` | `(data.key is string && data.key.size >= 5)` |
| `z.string().min(5).max(20)` | `(data.key is string && data.key.size >= 5 && data.key.size <= 20)` |
| `z.string().regex(/@example\.com$/)` | `(data.key is string && data.key.matches("@example\\.com$"))` |
| `z.number()` | `data.key is number` |
| `z.number().int()` | `data.key is int` |
| `z.number().min(5)` | `(data.key is int && data.key >= 5)` |
| `z.number().max(20)` | `(data.key is int && data.key <= 20)` |
| `timestampType()` | `data.key is timestamp` |
| `z.record(z.string())` | `data.key is map` |
| `z.tuple([z.string(), z.number()])` | `(data.key is list && data.key[0] is string && data.key[1] is number)` |
| `z.string().array()` | `data.key is list` |
| `z.string().array().min(5)` | `(data.key is list && data.key.size() >= 5)` |
| `z.string().array().max(20)` | `(data.key is list && data.key.size() <= 20)` |
| `z.string().optional()` | `(data.key is string \|\| !("key" in data))` |
| `z.union([z.string(), z.null()])` | `(data.key is string \|\| data.key == null)` |


### 1. Define schema

The schema definition must be default exported.

```ts
import { Merge } from 'type-fest'
import { z } from 'zod'

import { DataModel, FirestoreModel, rules, timestampType } from 'fireschema'

export const UserType = z.object({
name: z.string(),
displayName: z.union([z.string(), z.null()]),
age: z.number().int(),
timestamp: timestampType(),
options: z.object({ a: z.boolean() }).optional(),
})

type User = z.infer
/* => {
name: string
displayName: string | null
age: number
timestamp: FTypes.Timestamp
options?: { a: boolean } | undefined
} */

type UserDecoded = Merge

const UserModel = new DataModel({
schema: UserType,
decoder: (data: User): UserDecoded => ({
...data,
timestamp: data.timestamp.toDate(),
}),
})

const PostType = z.object({
authorUid: z.string(),
text: z.string(),
tags: z.object({ id: z.number().int(), name: z.string() }).array(),
})

const PostModel = new DataModel({
schema: PostType,
selectors: (q) => ({
byTag: (tag: string) => [
q.where('tags', 'array-contains', tag),
q.limit(20),
],
}),
})

export const firestoreModel = new FirestoreModel({
'function isAdmin()': `
return exists(${rules.basePath}/admins/$(request.auth.uid));
`,

'function requestUserIs(uid)': `
return request.auth.uid == uid;
`,

collectionGroups: {
'/posts/{postId}': {
allow: {
read: true,
},
},
},

'/users/{uid}': {
model: UserModel,
allow: {
read: true, // open access
write: rules.or('requestUserIs(uid)', 'isAdmin()'),
},

'/posts/{postId}': {
'function authorUidMatches()': `
return request.resource.data.authorUid == uid;
`,

model: PostModel,
allow: {
read: true,
write: rules.and('requestUserIs(uid)', 'authorUidMatches()'),
},
},
},
})

export default firestoreModel
```

Write rules are **combined** with the rules automatically generated from zod schema.


### 2. Generate firestore.rules

```sh
yarn fireschema rules .ts
```

> Environment variable `TS_NODE_PROJECT` is supported.

Example of generated firestore.rules

```rules
rules_version = '2';

service cloud.firestore {
match /databases/{database}/documents {
function __validator_meta__(data) {
return (
(request.method == "create" && data._createdAt == request.time && data._updatedAt == request.time)
|| (request.method == "update" && data._createdAt == resource.data._createdAt && data._updatedAt == request.time)
);
}

function __validator_keys__(data, keys) {
return data.keys().removeAll(['_createdAt', '_updatedAt']).hasOnly(keys);
}

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

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

match /{path=**}/posts/{postId} {
allow read: if true;
}

match /users/{uid} {
function __validator_0__(data) {
return (__validator_meta__(data) && (
__validator_keys__(data, ['name', 'displayName', 'age', 'timestamp', 'options'])
&& data.name is string
&& (data.displayName is string || data.displayName == null)
&& data.age is int
&& data.timestamp is timestamp
&& (data.options.a is bool || !("options" in data))
));
}

allow read: if true;
allow write: if ((requestUserIs(uid) || isAdmin()) && __validator_0__(request.resource.data));

match /posts/{postId} {
function authorUidMatches() {
return request.resource.data.authorUid == uid;
}

function __validator_1__(data) {
return (__validator_meta__(data) && (
__validator_keys__(data, ['authorUid', 'text', 'tags'])
&& data.authorUid is string
&& data.text is string
&& data.tags is list
));
}

allow read: if true;
allow write: if ((requestUserIs(uid) && authorUidMatches()) && __validator_1__(request.resource.data));
}
}
}
}
```


### 3. Read/write collections and documents

The Firestore interface of Fireschema supports both **Web SDK and Admin SDK**.

```ts
import { initializeApp } from 'firebase/app' // or firebase-admin
import { initializeFirestore } from 'firebase/firestore'

import { TypedFirestoreWeb } from 'fireschema'
import { firestoreModel } from './1-1-schema.js'

const app = initializeApp({
// ...
})
const firestoreApp = initializeFirestore(app, {
ignoreUndefinedProperties: true,
})

/**
* Initialize TypedFirestore
*/
export const $web: TypedFirestoreWeb =
new TypedFirestoreWeb(firestoreModel, firestoreApp)

/**
* Reference collections/documents and get snapshot
*/
const usersRef = $web.collection('users') // TypedCollectionRef instance
const userRef = usersRef.doc('userId') // TypedDocumentRef instance

const postsRef = userRef.collection('posts')
const postRef = postsRef.doc('123')
const techPostsQuery = postsRef.select.byTag('tech') // selector defined in schema

await userRef.get() // TypedDocumentSnap
await userRef.getData() // User | undefined
await userRef.getDataOrThrow() // User

await postRef.get() // TypedDocumentSnap
await postsRef.get() // TypedQuerySnap
await postsRef.getData() // (PostA | PostB)[]
await techPostsQuery.get() // TypedQuerySnap

/**
* Get child collection of retrived document snapshot
*/
const snap = await usersRef.get()
const firstUserRef = snap.docs[0]!.ref

await firstUserRef.collection('posts').get()

/**
* Reference parent collection/document
*/
const _postsRef = postRef.parentCollection()
const _userRef = postsRef.parentDocument()

/**
* Reference collections groups and get snapshot
*/
const postsGroup = $web.collectionGroup('posts')
const techPostsGroup = postsGroup.select.byTag('tech')

await postsGroup.get() // TypedQuerySnap
await techPostsGroup.get() // TypedQuerySnap

/**
* Write data
*/
await userRef.create(({ serverTimestamp }) => ({
name: 'test',
displayName: 'Test',
age: 20,
timestamp: serverTimestamp(),
options: { a: true },
}))
await userRef.setMerge({
age: 21,
})
await userRef.update({
age: 21,
})
await userRef.delete()

/**
* Transaction
*/
await $web.runTransaction(async (tt) => {
const snap = await tt.get(userRef)
tt.update(userRef, {
age: snap.data()!.age + 1,
})
})
```

#### Write methods of Fireschema's document reference

- **`create()`** - Create a document. (`_createdAt` / `_updatedAt` fields are added)
- **Web** - Call JS SDK's `set()` internally.
It fails if the document already exists because overwriting \_createdAt is denied by the automatically generated security rules.
- **Admin** - Call Admin SDK's `create()` internally. It fails if the document already exists.
- **`setMerge()`** - Call `set(data, { merge: true })` internally. (`_updatedAt` field is updated)
- **`update()`** - Call `update()` internally. (`_updatedAt` field is updated)

> `set()` is not implemented on fireschema because it cannot determine whether `_createdAt` should be included in update fields without specifying it is a new creation or an overwrite.


### 4. React Hooks

```tsx
import React, { Suspense } from 'react'

import { useTypedCollection, useTypedDoc } from 'fireschema/hooks'
import { $web } from './1-3-typed-firestore.js'

/**
* Get realtime updates of collection/query
*/
export const PostsComponent = () => {
const userRef = $web.collection('users').doc('user1')
const postsRef = userRef.collection('posts')

const posts = useTypedCollection(postsRef)
const techPosts = useTypedCollection(postsRef.select.byTag('tech'))

return (


    {posts.data.map((post, i) => (
  • {post.text}

  • ))}


)
}

/**
* Get realtime updates of document
*/
export const UserComponent = ({ id }: { id: string }) => {
const user = useTypedDoc($web.collection('users').doc(id))

return (

{user.data?.displayName}

)
}
```


---


## Usage - Cloud Functions

### 1. Create functions

```ts
import * as functions from 'firebase-functions'
import { z } from 'zod'

import { TypedFunctions } from 'fireschema/admin'
import { UserType, firestoreModel } from './1-1-schema.js'

/**
* Initialize TypedFunctions
*/
const timezone = 'Asia/Tokyo'
const typedFunctions = new TypedFunctions(firestoreModel, timezone)
const builder = functions.region('asia-northeast1')

/**
* functions/index.ts file
*/
export const UserJsonType = UserType.extend({ timestamp: z.string() })
export const callable = {
createUser: typedFunctions.callable({
schema: {
input: UserJsonType, // schema of request data (automatically validate on request)
output: z.object({ result: z.boolean() }), // schema of response data
},
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 (provided based on path string)
console.log(snap) // QueryDocumentSnapshot
},
}),
}

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: z.object({ text: z.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. Call HTTPS callable function

Automatically provide types to request/response data based on passed functions module type.

```tsx
import { initializeApp } from 'firebase/app'
import { getFunctions } from 'firebase/functions'
import React from 'react'

import { TypedCaller } from 'fireschema'

type FunctionsModule = typeof import('./2-1-typed-functions.js')

const app = initializeApp({
// ...
})
const functionsApp = getFunctions(app, 'asia-northeast1')

export const typedCaller = new TypedCaller(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
}
```