![Fireschema](https://github.com/yarnaimo/fireschema/raw/HEAD/./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
Install
yarn add fireschema
yarn add -D typescript ts-node
Setup
Custom Transformer
To generate Firestore security rules or embedding type validation code into Callable Function, you need to compile codes via Fireschema's custom transformer that retrives type information from TypeScript AST.
Currently official TypeScript package doesn't support custom transformers, and you need to use ttypescript that wraps TypeScript compiler.
Add following options to your tsconfig.json
,
{
"compilerOptions": {
"plugins": [
{
"transform": "fireschema/transformer"
}
]
}
}
and replace the commands.
| before | after |
---|
typescript | tsc | ttsc (ttypescript's compile command) |
ts-node | ts-node | ts-node --compiler ttypescript |
ttsc
and ts-node
supports specifying tsconfig.json
by using environment variable TS_NODE_PROJECT
.
If you use ts-jest, add following options to jest config.
module.exports = {
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json',
compiler: 'ttypescript',
},
},
}
Example - Firestore
Do not use following variable names except importing from fireschema.
Data structure of examples
users/{uid}
- User
users/{uid}/posts/{postId}
- PostA | PostB
1. Define schema
The schema definition must be named exported as firestoreSchema
.
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]: {
['isAdmin()']: `
return exists(/databases/$(database)/documents/admins/$(request.auth.uid));
`,
['matchesUser(uid)']: `
return request.auth.uid == uid;
`,
},
[$collectionGroups]: {
posts: {
[$docLabel]: 'postId',
[$schema]: PostSchema,
[$allow]: {
read: true,
},
},
},
users: {
[$docLabel]: 'uid',
[$schema]: UserSchema,
[$allow]: {
read: true,
write: $or(['matchesUser(uid)', 'isAdmin()']),
},
posts: {
[$docLabel]: 'postId',
[$schema]: PostSchema,
[$allow]: {
read: true,
write: 'matchesUser(uid)',
},
},
},
})
2. Generate firestore.rules
yarn fireschema <path-to-schema>.ts
Environment variable TS_NODE_PROJECT
is supported similarly to ttsc
and ts-node
.
Example of generated 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=**}/posts/{postId} {
allow read: if true;
}
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. Read/write collections and documents
The Firestore interface of Fireschema supports both Web SDK and Admin SDK.
import firebase from 'firebase/app'
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 })
export const typedFirestore: TypedFirestore<
typeof firestoreSchema,
firebase.firestore.Firestore
> = new TypedFirestore(firestoreSchema, firebase.firestore, firestoreApp)
const users = typedFirestore.collection('users')
const user = users.doc('userId')
const posts = user.collection('posts')
const post = posts.doc('123')
const techPosts = user.collectionQuery(
'posts',
(q) => q.byTag('tech'),
)
!(async () => {
await user.get()
await post.get()
await posts.get()
await techPosts.get()
})
!(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',
'users.posts',
)
const techPostsGroup = typedFirestore.collectionGroupQuery(
'posts',
'users.posts',
(q) => q.byTag('tech'),
)
!(async () => {
await postsGroup.get()
await techPostsGroup.get()
})
!(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>
}
Example - Cloud Functions
1. Create functions
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'
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)
return { result: true }
},
}),
}
export const firestoreTrigger = {
onUserCreate: typedFunctions.firestoreTrigger.onCreate({
builder,
path: 'users/{uid}',
handler: async (decodedData, snap, context) => {
console.log(decodedData)
console.log(snap)
},
}),
}
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
},
}),
}
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.
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}></button>
}