👻

Firebase Functionsのフロントエンド側の型をバックエンドの型から自動生成するライブラリを作りました

2022/05/27に公開

概要

Firebase FuncitonsのhttpsOnCall APIはSwaggerのように自動でAPIの型を生成してクライアントで利用できるようにする仕組みが自分の観測範囲では見つけられなかったので作りました。

firebase-function-client-type-gen

多少関数定義を行う際に制約があるものの実運用できるレベルには仕上がったので公開します。

使い方

functions/includeTest.ts
import * as functions from 'firebase-functions'

// You define two types in function definition file and they must be in a file include function declaration.
type RequestArgs = {
    id: string
}
type ResponseResult = {
    result: 'ok' | 'ng'
}

// You must export "only one const https onCall" in a file.
// If you export many httpsOnCall functions, it may happen unexpected result when mapping args and result types.'
export const includeTest = functions
    .region('asia-northeast1')
    .runWith({
        memory: '1GB'
    })
    .https.onCall((data: RequestArgs, _): ResponseResult => {
        return {
            result: 'ok'
        }
    })
functions/index.ts
import { includeTest } from './includeTest'

export const nameSpace = {
    includeTest
}

以上のようなnameSpace使ったfirebase functionsを用意したとします。本ライブラリではTypescript Compiler APIを使用して、httpsのメソッドを使った関数名とリクエストの型とレスポンスの型を抽出して、アウトプットする仕組みを作りました。この際deployするfunction名を取得する時にオブジェクトのキーを辿って関数名を取得しますが、この時に実際のruntimeのコードが動いてしまうので、動くとエラーになってしまうコードはモックにします。

export const DUMMY_MOCKS = new Proxy<any>(
    () => DUMMY_MOCKS,
    {
        get(_, __): any {
            return DUMMY_MOCKS
        }
    }
)

export const MOCKS_BASE = {
    'firebase-functions': {
        region() {
            return DUMMY_MOCKS
        },
        config: () => {
            return {
            }
        },
        '@global': true,
        '@noCallThru': true
    },
    'firebase-admin': {
        apps: DUMMY_MOCKS,
        initializeApp: () => { return DUMMY_MOCKS },

        '@global': true,
        '@noCallThru': true
    },
}

export const MOCKS = new Proxy(MOCKS_BASE, {
    get(target, name) {
        const returnValue = target[name as keyof typeof MOCKS_BASE]
        return returnValue ?? DUMMY_MOCKS
    }
})

以上のようなモックを作成し、型生成用のコードを作成します。もし利用するプログラム内のトップレベルのスコープで発火してエラーを引き起こす関数が存在している場合はその他のモックを追加してください。ここではproxyquireを使ったmockを紹介しています。

モックが用意できたら以下のように出力用のプログラムを用意します。

import proxyquire from 'proxyquire'
import { MOCKS } from './mock'
import { outDefinitions } from 'firebase-function-client-type-gen'
import path from 'path'
import glob from 'glob'
import {EOL} from 'os'

const functionDefs = proxyquire('./functions/entrypoint.ts', Mocks)

// Get document, or throw exception on error
try {
  const sources = glob.sync(path.resolve(__dirname, './', 'functions/**/*.ts'))
  const result = outDefinitions(sources, namedFunctions, {
    symbolConfig: {
      args: 'RequestArgs',
      result: 'ResponseResult'
    }
  })
  console.log(result)
  console.log('named functions type generated' + EOL);
} catch (e) {
  console.error(e);
}

globではfunctions httpsOnCallableの定義場所が全て含まれるように指定します。上記のコードを出力した場合以下のようになります。

export type FunctionDefinitions = {
    "includeTest": {
        args: { id: string; };
        result: { result: "ok" | "ng"; };
    };
};

export const functionsMap = {
    includeTest: "nameSpace-includeTest",
};

以上の出力部分をfs.writeFileSyncを用いてファイルに吐き出すなどをして、client側でこの型定義とfunction nameのマップを呼び出せるようにします。
クライアント側では以下のようなfunction関数を生成するhelperを用意して、実行時引数に生成したオブジェクトと型をタイプパラメータ引数に渡すことでタイプセーフなAPIクライアントが動的に生成できます。

import { getFunctions, httpsCallable, HttpsCallable } from 'firebase/functions'
import { getApp } from 'firebase/app'

type IFunctionDefnitions = {
    [key: string]: {
        args: any,
        result: any
    }
}

type HttpsCallableFuntions<FunctionDefnitions extends IFunctionDefnitions> = {
    [functionName in keyof FunctionDefnitions]: HttpsCallable<FunctionDefnitions[functionName]['args'], FunctionDefnitions[functionName]['result']>
}


type HttpsCallableFuntionIds<FunctionDefnitions> = {
    [functionName in keyof FunctionDefnitions]: string
}

export function initializeFunctions<FunctionDefnitions extends IFunctionDefnitions>(functionNameObject: HttpsCallableFuntionIds<FunctionDefnitions>, app = getApp(), region = 'us-east-1'): HttpsCallableFuntions<FunctionDefnitions> {
    const functions = getFunctions(app, region)
    const functionDefinitions = Object.entries(functionNameObject)
    return functionDefinitions.reduce((current, [functionName, functionId]) => {
        return {
            ...current,
            [functionName]: httpsCallable(functions, functionId)
        }
    }, {} as HttpsCallableFuntions<FunctionDefnitions>)
}

// At your entrypoint file, import generated types from your generated types file.
import { FunctionDefinitions, functionsMap } from './functions-types'
const client = initializeFunctions<FunctionDefinitions>(functionsMap)
// Fully type-safed api call functions.
client.callSomethingReuest({...args})

ただし、regionなどの指定を関数ごとに行いたい場合は、以上のようにループで関数を取得するのではなく、個別にhttpsCallableに型引数とidを指定するか、regionを自前でループに組み込んでください。

Typescript Compiler apiからregionを取得することもできそうでしたが自分の用途では一旦困らなかったので実装はしていません。利用・改善提案・PR歓迎です。

注意点

  • 関数定義の方法を制約することでType Aliasや関数名を取得するので、ライブラリによって関数定義の仕方が制約されます。
  • regionの取得は未実装なのでregionを関数ごとに設定変更したい場合はプログラム側で解決する必要があります。
  • 現在ArgsとResponseの型定義にtype Aliasを使えない制約があります。かなり不便なのでそのうち直したい。

更新

  • type aliasとinterfaceのリファレンスも参照を引っ張って来られるようになりました。
  • region取得も実装されました。

その他

Dev.toに英語解説記事も載せてます。

Discussion