Why Do You Need This? What Is The Problem FireCall Trying To Solve?

When coding a callable function (or any endpoint in general), we need to deal with 5 basic errors, which is basically 99% of your errors, the rest are system errors.

  1. Unauthenticated error (only for protected route)
  2. invalid request data error
  3. invalid response data error (this is needed if we want to prevent unnecessary data send to front end)
  4. developer defined error, whatever developer do and whatever error he want to throw
  5. unknown error that happen for whatever reason (basically error that is not taken care by developer)

Error handling is chaotic, error handling is hard, error handling make you go nut.

Some developer return error as 200 response and attach his own error code and message as data, and imagine every developer return his unique format of error, this is not fun.

With FireCall, no more "you return your error, I return my error, he return his error", everybody simply return a god damn standard HTTPS error.

FireCall standardize the way of handling these errors for you.

There is also one common issue where developer often calling the wrong function name which lead to CORS error, basically front end and backend is not tally with each other.

So to solve this is we prepare a schema and share it to both front end and back end, by doing this not only we make sure that the function name is correct, but also we make sure that the data type is correct.

It is very similar to how Graphql schema sharing works, but way much simpler and we all know how convoluted Graphql is.

Long thing short, FireCall make sure that there is only one way to do stuff and giving you absolute type safe on both compile and run time.

Related Projects

  1. FirelordJS - Typescript wrapper for Firestore Web V9
  2. Firelord - Typescript wrapper for Firestore Admin
  3. Firelordrn - Typescript wrapper for Firestore React Native
  4. FireLaw - Write Firestore security rule with Typescript, utilizing Firelord type engine.

Installation

npm i firecall zod firebase-functions regenerator-runtime

and of course you need typescript.

Add this to your very first line of code

import 'regenerator-runtime/runtime'

You only need to add this line once

Create Schema With Zod

First, you need to create schema with zod, you can share this file to front end and use FireCaller with it.

FireCall can works without FireCaller on front end but it is recommended to use FireCaller with it or else there is no point sharing schema to front end.

import { z } from 'zod'

export const updateUserSchema = {
    //request data schema
    req: z.object({
        name: z.string(),
        age: z.number(),
        address: z.string(),
    }),
    // response data schema
    res: z.undefined(),
    // function name
    name: 'updateUser',
}

export const getUserSchema = {
    res: z.string(), // userId
    res: z.object({
        name: z.string(),
        age: z.number(),
    }),
    name: 'getUser',
}

req: request data schema
res: response data schema
name: onCall function name

Create the onCall Functions

import { updateUserSchema, getUserSchema } from './someFiles'
import { onCall } from 'firecall'

// use any variable name you want
const updateUser = onCall(
    updateUserSchema,
    { route: 'private' }, // 'private' for protected route
    // handler
    async (data, context) => {
        const { name, age, address } = data // request data is what you define in schema.req
        const {
            auth: { uid }, // if route is protected, auth object is not undefined
        } = context

        try {
            await updateWithSomeDatabase({ uid, name, age, address })
            return { code: 'ok', data: undefined } // response data is what you define in schema.res
        } catch (err) {
            // in case you are not catching any error, FireCall will also throw unknown error
            return {
                code: 'unknown',
                message: 'update user failed',
                err,
            }
        }
    }
)

const getUser = onCall(
    getUserSchema,
    { route: 'public' }, // 'public' for unprotected route
    // handler
    async data => {
        const uid = data // request data is what you define in schema.req

        try {
            const { name, age, secret } = await getUserFromDatabase({
                uid,
            })
            return { code: 'ok', data: { name, age } } // response data is what you define in schema.res
        } catch (err) {
            // in case you are not catching any error, FireCall will also throw unknown error
            return {
                code: 'unknown',
                message: 'get user failed',
                err,
            }
        }
    }
)

If the response is ok, handler must return object with code and data property, where
code: 'ok'
data: value that has same type as type you define in schema.res

if the response is not ok, handler must return object with code and message properties, and an optional err property, where
code: Firebase Functions Error Code except 'ok'
message: string
err: optional, user defined error, put anything you want here, normally the error object or just skip it

Export Functions

This is helper function to export functions. Since function name is now an object property, we need a runtime check(deploy phase runtime) to make sure each function name is unique and throw error if duplicate found.

import { updateUser, getUser } from './someOtherFile'
import { exp } from 'firecall'

exp({ updateUser, getUser }).forEach(func => {
    const { name, onCall } = func
    exports[name] = onCall
})

If everything in someOtherFile is FireCall function, you can write something like this

import * as allFunc from './someOtherFile'
import { exp } from 'firecall'

exp(allFunc).forEach(func => {
    const { name, onCall } = func
    exports[name] = onCall
})

Firebase Function Test

You can use FireCall with firebase-functions-test:

ok test example:

const wrapped = test.wrap(
    onCall(
        schema,
        {
            route: 'private',
        },
        async () => {
            return { code: 'ok', data: 'okie' }
        }
    ).onCall
)
await expect(wrapped('123', { auth: { uid: '123' } })).resolves.toEqual('okie')

error test examples:

const wrapped = test.wrap(
    onCall(
        schema,
        {
            route: 'private',
        },
        () => {
            return { code: 'cancelled', message: 'cancelled' }
        }
    ).onCall
)
await expect(wrapped('123', { auth: { uid: '123' } })).rejects.toEqual(
    new functions.https.HttpsError('cancelled', 'cancelled')
)

const wrapped = test.wrap(
    onCall(
        schema,
        {
            route: 'private',
        },
        async () => {
            return { code: 'ok', data: 'okRes' }
        }
    ).onCall
)
await expect(wrapped('123')).rejects.toEqual(
    new functions.https.HttpsError('unauthenticated', 'Please Login First')
)

Const Assertion

You can use const assertion if the handler is returning response from another callback, example from the transaction.

import { onCall } from 'firecall'

export const someFun = onCall(someSchema, { route: 'private' }, async () => {
    // return the transaction
    return await db.runTransaction(async transaction => {
        return { code: 'ok', data: null } as const // do const assertion here
    })
})

Function Builder

If you need custom setting for you function like changing ram or region, you can pass function builder to onCall config.

import * as functions from 'firebase-functions'
import { onCall } from 'firecall'

const someFunc = onCall(
    someSchema,
    {
        route: 'public', // route is not optional, you can use either 'public' or 'private' value
        func: functions
            .runWith({
                timeoutSeconds: 300,
                memory: '1GB',
            })
            .region('europe-west1'),
    },
    handler
)

func accept functions or functions.FunctionBuilder

Error Logging Options

By default, FireCall do not log the necessary information upon error. Pass a function to config.onErrorLogging.

Do this if you want to log:

const someFunc = onCall(
    someSchema,
    {
        route: 'public', // route is not optional, you can use either 'public' or 'private' value
        config: {
            onErrorLogging: ({ context, reqData, reqZodError, resZodError, err }) => {
                // do something here, eg save to file

                return X // log X on the console, X is an object literal
            },
        },
    },
    handler
)

onErrorLogging: optional, ({ reqData, context, reqZodError?, resZodError?, err? })=> Record<string,unknown> & { logType?: 'log' | 'info' | 'warn' | 'error' }

reqData: the request data
context: Firebase function context callable
reqZodError: may exist, the error that occurs when trying to parse the request data
resZodError: may exist, the error that occurs when trying to parse the response data
err: may exist, it is the user defined error you return to the handler(the response). Its type is unknown until there is user defined error in the response, which mean you don't need to type cast, FireCall will infer all the type for you.

Note: Logging doesn't include saving it to a file or somewhere, it only logs it to the Firebase functions console. If you want to save the errors, then do it within function form.

Whatever object literal the function return and(empty object = nothing to log) get logged on the console, except the logType props.

logType props is an optional prop that set the type of your log, by default it is error.

Change Built In Error Message

Here is how you change the built in error message:

const someFunc = onCall(
    someSchema,
    {
        route: 'public', // route is not optional, you can use either 'public' or 'private' value
        config: {
            changeBuiltInErrorCodeAndMessage: {
                unauthenticated: {
                    code: 'someCode' // default unauthenticated
                    message: 'someMessage' // default Please Login First
                },
                unknown: {
                    code: 'someCode' // default unknown
                    message: 'someMessage' // default unknown
                },
                resZodError: {
                    code: 'someCode' // default invalid-argument
                    message: 'someMessage' // default invalid-argument
                },
                reqZodError: {
                    code: 'someCode' // default internal
                    message: 'someMessage' // default invalid response
                }
            },
        },
    },
    handler
)

Every prop of changeBuiltInErrorCodeAndMessage is optional, if no values are supplied, it uses default codes and messages.

The code value is limited to Firebase Functions Error Code except 'ok'.