prescottprue/fireadmin

View on GitHub
functions/src/actionRunner/actions.ts

Summary

Maintainability
C
1 day
Test Coverage
import * as admin from 'firebase-admin'
import { get, chunk, isObject } from 'lodash'
import { batchCopyBetweenFirestoreRefs } from './utils'
import { downloadFromStorage, uploadToStorage } from '../utils/cloudStorage'
import { to, promiseWaterfall } from '../utils/async'
import { isDocPath } from '../utils/firestore'
import { shallowRtdbGet } from './utils'
import { ActionStep, ActionRunnerEventData } from './types'

/**
 * Create data object with values for each document with keys being doc.id.
 * @param snap - Data for which to create
 * an ordered array.
 * @returns Object documents from snapshot or null
 */
function dataByIdSnapshot(snap) {
  const data = {}
  if (snap.data && snap.exists) {
    data[snap.id] = snap.data()
  } else if (snap.forEach) {
    snap.forEach((doc) => {
      data[doc.id] = doc.data() || doc
    })
  }
  return Object.keys(data).length ? data : null
}

/**
 * Copy data between Firestore instances from two different Firebase projects
 * @param app1 - First app for the action
 * @param app2 - Second app for the action
 * @param eventData - Data from event (contains settings)
 * @param inputValues - Values of inputs
 * @returns Resolves with result of update call
 */
export async function copyBetweenFirestoreInstances(
  app1: admin.app.App,
  app2: admin.app.App,
  eventData: ActionStep,
  inputValues: any[]
) {
  const { merge = true, subcollections } = eventData
  const srcPath = inputValueOrTemplatePath(eventData, inputValues, 'src')
  const destPath = inputValueOrTemplatePath(eventData, inputValues, 'dest')
  const dataPathMethod = isDocPath(srcPath) ? 'doc' : 'collection'

  // Get Firestore references from slash paths (handling both doc and collection)
  const srcRef = app1.firestore()[dataPathMethod](srcPath)
  const destRef = app2.firestore()[dataPathMethod](destPath)

  // Copy from src ref to dest ref with support for merging and subcollections
  const [copyErr, writeRes] = await to(
    batchCopyBetweenFirestoreRefs({
      srcRef,
      destRef,
      opts: { merge, copySubcollections: subcollections }
    })
  )

  // Handle errors copying between Firestore Refs
  if (copyErr) {
    console.error('Error copying data between Firestore refs: ', {
      message: copyErr.message || copyErr,
      srcPath,
      destPath
    })
    throw copyErr
  }

  console.log('Copy between Firestore instances successful!')

  return writeRes
}

/**
 * Copy data from Cloud Firestore to Firebase Real Time Database
 * @param app1 - First app for the action
 * @param app2 - Second app for the action
 * @param eventData - Data from event (contains settings)
 * @param inputValues - Values of inputs
 * @returns Resolves with result of update call
 */
export async function copyFromFirestoreToRTDB(
  app1: admin.app.App,
  app2: admin.app.App,
  eventData: ActionStep,
  inputValues: any[]
): Promise<any> {
  const firestore1 = app1.firestore()
  const secondRTDB = app2.database()
  const destPath = inputValueOrTemplatePath(eventData, inputValues, 'dest')
  const srcPath = inputValueOrTemplatePath(eventData, inputValues, 'src')

  // Get Firestore instance from slash path (handling both doc and collection)
  const dataPathMethod = isDocPath(srcPath) ? 'doc' : 'collection'
  const srcRef = firestore1[dataPathMethod](srcPath)

  // Get data from first instance
  const [getErr, firstSnap] = await to(srcRef.get())

  // Handle errors getting original data
  if (getErr) {
    console.error(
      `Error getting data from first firestore instance: ${
        getErr.message || ''
      }`,
      getErr
    )
    throw getErr
  }

  // Get data into array (regardless of single doc or collection)
  const dataFromSrc = dataByIdSnapshot(firstSnap)

  // Handle no data within provided source path
  if (!dataFromSrc) {
    const noDataErr = 'No data exists within source path'
    console.error(noDataErr)
    throw new Error(noDataErr)
  }

  // Write data to destination RTDB path
  const [updateErr] = await to(secondRTDB.ref(destPath).update(dataFromSrc))

  // Handle errors writing data to destination RTDB
  if (updateErr) {
    console.error(
      'Error copying from Firestore to RTDB',
      updateErr.message || updateErr
    )
    throw updateErr
  }

  console.log('Copy from Firestore to RTDB successful!')

  return null
}

/**
 * Copy data from Real Time Database to Cloud Firestore
 * @param app1 - First app for the action
 * @param app2 - Second app for the action
 * @param eventData - Data from event (contains settings)
 * @param inputValues - Values of inputs
 * @returns Resolves with result of update call
 */
export async function copyFromRTDBToFirestore(
  app1: admin.app.App,
  app2: admin.app.App,
  eventData: ActionStep,
  inputValues: any[]
) {
  const firestore2 = app2.firestore()
  const firstRTDB = app1.database()
  const destPath = inputValueOrTemplatePath(eventData, inputValues, 'dest')
  const srcPath = inputValueOrTemplatePath(eventData, inputValues, 'src')
  try {
    const dataSnapFromFirst = await firstRTDB.ref(srcPath).once('value')
    const dataFromFirst = dataSnapFromFirst.val()
    const updateRes = await firestore2.doc(destPath).update(dataFromFirst)
    console.log('Copy from RTDB to Firestore successful')
    return updateRes
  } catch (err) {
    console.error('Error copying from RTDB to Firestore', err.message || err)
    throw err
  }
}

/**
 * Get input value if pathtype is input otherwise get path value from template
 * @param templateStep - Step from which to get pathType and fallback paths.
 * @param inputValues - Converted input values
 * @param [location='src'] - Path location (i.e. src/dest)
 * @returns Inputs value or path provided within template's step
 */
function inputValueOrTemplatePath(templateStep: ActionStep, inputValues: any[], location = 'src'): any {
  return get(templateStep, `${location}.pathType`) === 'input'
    ? get(inputValues, get(templateStep, `${location}.path`))
    : get(templateStep, `${location}.path`)
}

/**
 * Copy data between Firebase Realtime Database Instances
 * @param app1 - First app for the action
 * @param app2 - Second app for the action
 * @param eventData - Data from event (contains settings)
 * @param inputValues - Converted input values
 * @returns Resolves with result of update call
 */
export async function copyBetweenRTDBInstances(
  app1: admin.app.App,
  app2: admin.app.App,
  eventData: ActionStep,
  inputValues: any[]
): Promise<null> {
  if (!app1?.database || !app2?.database) {
    console.error('Database not found on app instance')
    throw new Error('Invalid service account, does not have access to database')
  }
  try {
    const firstRTDB = app1.database()
    const secondRTDB = app2.database()
    const destPath = inputValueOrTemplatePath(eventData, inputValues, 'dest')
    const srcPath = inputValueOrTemplatePath(eventData, inputValues, 'src')

    // Handle source path not being provided
    if (!srcPath) {
      const noSrcPathErr =
        'Copying whole database is not currently supported, please provide a source path.'
      console.warn('Attempted to copy whole database, throwing an error')
      throw new Error(noSrcPathErr)
    }
    // Load data from first database
    const dataSnapFromFirst = await firstRTDB.ref(srcPath).once('value')
    const dataFromFirst = dataSnapFromFirst.val()

    // Handle data not existing in source database
    if (!dataFromFirst) {
      const errorMessage =
        'Path does not exist in Source Real Time Database Instance'
      console.error(errorMessage)
      throw new Error(errorMessage)
    }
    const writeMethod = isObject(dataFromFirst) ? 'update' : 'set'

    // Update second database with data from first datbase
    await secondRTDB.ref(destPath)[writeMethod](dataFromFirst)

    console.log('Copy between RTDB instances successful')

    return null
  } catch (err) {
    console.error('Error copying between RTDB instances', err.message || err)
    throw err
  }
}

/**
 * Copy data between Firebase Realtime Database Instances
 * @param app1 - First app for the action
 * @param app2 - Second app for the action
 * @param srcPath - Data source path
 * @param destPath - Data destination path
 * @returns Resolves with result of update call
 */
export async function copyPathBetweenRTDBInstances(
  app1: admin.app.App,
  app2: admin.app.App,
  srcPath: string,
  destPath: string
): Promise<null> {
  if (!app1?.database || !app2?.database) {
    console.error('Database not found on app instance')
    throw new Error('Invalid service account, does not have access to database')
  }
  try {
    const firstRTDB = app1.database()
    const secondRTDB = app2.database()

    // Handle source path not being provided
    if (!srcPath) {
      const noSrcPathErr =
        'Copying whole database is not currently supported, please provide a source path.'
      console.warn('Attempted to copy whole database, throwing an error')
      throw new Error(noSrcPathErr)
    }
    // Load data from first database
    const dataSnapFromFirst = await firstRTDB.ref(srcPath).once('value')
    const dataFromFirst = dataSnapFromFirst.val()

    // Handle data not existing in source database
    if (!dataFromFirst) {
      const errorMessage =
        `Path does not exist in Source Real Time Database Instance for path ${srcPath}`
      console.error(errorMessage)
      throw new Error(errorMessage)
    }
    const writeMethod = isObject(dataFromFirst) ? 'update' : 'set'

    // Update second database with data from first datbase
    await secondRTDB.ref(destPath || srcPath)[writeMethod](dataFromFirst)

    return null
  } catch (err) {
    console.error('Error copying between RTDB instances', err.message || err)
    throw err
  }
}

const DEFAULT_RTDB_BATCH_SIZE = 50

/**
 * Copy data between Firebase Realtime Database Instances in batches (suited for large data sets)
 * @param app1 - First app for the action
 * @param app2 - Second app for the action
 * @param step - Current step
 * @param inputValues - Converted input values
 * @param eventData - Data from event (contains settings)
 * @returns Resolves with result of update call
 */
export async function batchCopyBetweenRTDBInstances(
  app1: admin.app.App,
  app2: admin.app.App,
  step: ActionStep,
  inputValues: any[],
  eventData: ActionRunnerEventData
): Promise<void> {
  // TODO: Support passing in chunk size (it will have to be validated)
  const chunkSize = DEFAULT_RTDB_BATCH_SIZE

  const srcPath = inputValueOrTemplatePath(step, inputValues, 'src')
  const destPath = inputValueOrTemplatePath(step, inputValues, 'dest')
  const { projectId, environmentValues = [] } = eventData

  // TODO: Look into a smarter way to get environmentId since first may not always be source
  const shallowConfig = { projectId, environmentId: environmentValues[0] }
  // Call Firebase REST API using shallow: true to get a list of keys to chunk into groups
  const shallowResults = await shallowRtdbGet(shallowConfig, srcPath)

  // Handle source path not being provided
  if (!srcPath) {
    const noSrcPathErr =
      'Copying whole database is not currently supported, please provide a source path.'
    console.warn('Attempted to copy whole database, throwing an error')
    throw new Error(noSrcPathErr)
  }

  const keysChunks = chunk(Object.keys(shallowResults), chunkSize)

  await promiseWaterfall(
    keysChunks.map((keyChunk, i) => {
      return () => {
        console.log(`Copying key chunk: "${i}" from path: "${srcPath}"`)
        return Promise.all(
          keyChunk.map((rtdbKey) =>
            copyPathBetweenRTDBInstances(
              app1,
              app2,
              `${srcPath}/${rtdbKey}`,
              `${destPath}/${rtdbKey}`
            )
          )
        )
      }
    })
  )

  console.log('Batch copy between RTDB instances successful')
}

/**
 * Copy JSON from Firebase Real Time Database to Google Cloud Storage
 * @param app1 - First app for the action
 * @param app2 - Second app for the action
 * @param eventData - Data from event (contains settings)
 * @returns Resolves with result of update call
 */
export async function copyFromStorageToRTDB(app1: admin.app.App, app2: admin.app.App, eventData: ActionStep) {
  if (!app1?.database || !app2?.database) {
    throw new Error('Invalid service account, database not defined on app')
  }
  const secondRTDB = app2.database()
  const { src, dest } = eventData
  try {
    const dataFromFirst = await downloadFromStorage(app1, src.path)
    const updateRes = await secondRTDB.ref(dest.path).update(dataFromFirst)
    console.log('Copy from Storage to RTDB was successful')
    return updateRes
  } catch (err) {
    console.error('Error copying from storage instances', err.message || err)
    throw err
  }
}

/**
 * Copy JSON from Cloud Storage to Firebase Real Time Database
 * @param app1 - First app for the action
 * @param app2 - Second app for the action
 * @param eventData - Data from event (contains settings)
 * @returns Resolves with result of update call
 */
export async function copyFromRTDBToStorage(app1: admin.app.App, app2: admin.app.App, eventData: ActionStep) {
  if (!app1?.database) {
    throw new Error('Invalid service account, database not defined on app1')
  }
  const { src, dest } = eventData
  try {
    const firstRTDB = app1.database()
    const firstDataSnap = await firstRTDB.ref(src.path).once('value')
    const firstDataVal = firstDataSnap.val()
    if (!firstDataVal) {
      throw new Error('Data not found at provided path')
    }
    // TODO: Handle non-json values
    await uploadToStorage(app2, dest.path, firstDataVal)
    console.log('copy from RTDB to Storage was successful')
  } catch (err) {
    console.error('Error copying from RTDB to Storage: ', err.message || err)
    throw err
  }
}