Search code examples
javascriptnode.jsfirebaseexpressfirebase-admin

Handle multiple Firebase Projects in the BackEnd Javascript


I have many apps in many projects and I want to get information about them to update them later, so I decide to create an API with NodeJS and I'm configuring the way my endpoint can retrieve information from different projects such as count the number of apps in the specific project, I know that I need the Service Account key for each project and I already download them so my core logic is an endpoint like this

const { initProject } = require('../config/firebaseConfig')
async function getAllApps(req, res) {
  const projectId = req.params.projectId;
  const project = await initProject(projectId);

  try {
    androidApps = await project.projectManagement().listAndroidApps();
    iosApps = await project.projectManagement().listIosApps();

    const allApps = [...androidApps, ...iosApps];

    const appList = allApps.map((app) => ({
      appId: app.appId
    }));

    res.status(200).json(appList);

  } catch (error) {
    console.error('Error retrieving apps:', error);
    res.status(500).json({ error: 'Failed to retrieve apps for the project' });
  }
}

and the initProject is

const admin = require('firebase-admin');

// Initialize a project cache
const projectCache = new Map();

//initialize the project based on it's id name
async function initProject(projectId) {
    // Check if the project has already been initialized
    if (projectCache.has(projectId)) {
        return projectCache.get(projectId);
    }

    try {
        // Construct the path to the service account key file using the provided path or a default value
        if (process.env.FIREBASE_ACCOUNT_SERVICE_PATH !== null) {
            var serviceAccountPath = `${process.env.FIREBASE_ACCOUNT_SERVICE_PATH}\\${projectId}.json`;
        } else {
            var serviceAccountPath = `C:\\path\\to\\cred\\${projectId}.json`;
        }

        // Initialize the Firebase Admin SDK
        const serviceAccount = require(serviceAccountPath);

        admin.initializeApp({
            credential: admin.credential.cert(serviceAccount),
            databaseURL: `https://${projectId}.firebaseio.com`,
        });

        // Get the initialized Firebase project
        const firebaseProject = admin;

        // Cache the initialized project
        projectCache.set(projectId, firebaseProject);

        return firebaseProject;

    } catch (error) {

        console.error('Error initializing Firebase project: ', error);
        throw new Error('Failed to initialize Firebase project');
    }
}

module.exports = {
    initProject
}

the route is like this

router.get('/:projectId/apps', projectController.getAllApps);

so I start the express as well and the call for the first endpoint is this http://localhost:99/projects/project-name/apps so it works just fine my first call result in a success and I can receive what I need but if I try to call this endpoint again but with another project http://localhost:99/projects/project-name-2/apps it will fail because I already have initialize the firebase-admin without specify an app, but my need is exactly this, count the number of apps for each project for example, so I need to initialize it, I've tried to change this to kill the require as soon I get out of the firebaseConfig like this

// Initialize a project cache
const projectCache = new Map();

// Function factory to create a new Firebase App instance
function createFirebaseApp(projectId) {
    return () => {
        if (projectCache.has(projectId)) {
            const cachedProject = projectCache.get(projectId);
            return cachedProject;
        }

        try {
            // Construct the path to the service account key file using the provided path or a default value
            if (process.env.FIREBASE_ACCOUNT_SERVICE_PATH !== null) {
                var serviceAccountPath = `${process.env.FIREBASE_ACCOUNT_SERVICE_PATH}\\${projectId}.json`;
            } else {
                var serviceAccountPath = `C:\\EscolarManager\\Dados\\AppsCred\\${projectId}.json`;
            }

            // Initialize a new Firebase Admin SDK for each project
            const admin = require('firebase-admin');
            const serviceAccount = require(serviceAccountPath);

            const firebaseProject = admin.initializeApp({
                credential: admin.credential.cert(serviceAccount),
                databaseURL: `https://${projectId}.firebaseio.com`,
            });

            // Cache the initialized project
            projectCache.set(projectId, firebaseProject);

            return firebaseProject;
        } catch (error) {
            console.error('Error initializing Firebase project: ', error);
            throw new Error('Failed to initialize Firebase project');
        }
    }
}

module.exports = {
    createFirebaseApp
}

and I've tried to make a factory function too

const admin = require('firebase-admin');

// Initialize a project cache
const projectCache = new Map();

// Function factory to create a new Firebase App instance
function createFirebaseApp(projectId) {
    return () => {
        if (projectCache.has(projectId)) {
            const cachedProject = projectCache.get(projectId);
            return cachedProject;
        }

        try {
            // Construct the path to the service account key file using the provided path or a default value
            if (process.env.FIREBASE_ACCOUNT_SERVICE_PATH !== null) {
                var serviceAccountPath = `${process.env.FIREBASE_ACCOUNT_SERVICE_PATH}\\${projectId}.json`;
            } else {
                var serviceAccountPath = `C:\\EscolarManager\\Dados\\AppsCred\\${projectId}.json`;
            }

            // Initialize the Firebase Admin SDK without specifying an app name
            const serviceAccount = require(serviceAccountPath);

            const firebaseProject = admin.initializeApp({
                credential: admin.credential.cert(serviceAccount),
                databaseURL: `https://${projectId}.firebaseio.com`,
            });

            // Cache the initialized project
            projectCache.set(projectId, firebaseProject);

            return firebaseProject;
        } catch (error) {
            console.error('Error initializing Firebase project: ', error);
            throw new Error('Failed to initialize Firebase project');
        }
    }
}

// Example usage of the factory to create new Firebase App instances
const projectFactory = createFirebaseApp('project1');

const app1 = projectFactory();
const app2 = projectFactory();

console.log(app1 === app2); // This should print false, indicating they are different instances

but none of them works, same result. I'm caching the the project to not need to initialize the same project twice but call different projects is my issue here


The solution offered by 'samthecodingman' elegantly resolved the issue. I will now share the complete solution with a minor adjustment, which involves importing the service. I want to express my gratitude to him; thanks to his help, I now have a clearer understanding of this library. It functions in a manner similar to a set of functions, allowing me to simply call the functions and utilize the contents of its classes, such as the ProjectManagement class.

here is the controller

// Should return the list of apps id for this project
async function getAllApps(req, res) {
  const projectId = req.params.projectId;

  const project = getFirebaseAdminForProject(projectId);
  const projectManagement = getProjectManagement(project);

  try {
    androidApps = await projectManagement.listAndroidApps();
    iosApps = await projectManagement.listIosApps();

    const allApps = [...androidApps, ...iosApps];

    const appList = allApps.map((app) => ({
      appId: app.appId
    }));

    res.status(200).json(appList);

  } catch (error) {
    console.error('Error retrieving apps:', error);
    res.status(500).json({ error: 'Failed to retrieve apps for the project' });
  }
}

so the function he provided

const { initializeApp, getApps, cert } = require('firebase-admin/app');

function getFirebaseAdminForProject(projectId) {
    if (!projectId)
        throw new Error('Project ID is required!');

    let projectApp = getApps().find(app => app.name === projectId);

    if (projectApp)
        return projectApp;

    // if here, project is not yet initialized
    let serviceAccountPath = process.env.FIREBASE_ACCOUNT_SERVICE_PATH != null
        ? `${process.env.FIREBASE_ACCOUNT_SERVICE_PATH}\\${projectId}.json`
        : `C:\\path\\to\\${projectId}.json`;

    // Initialize the Firebase Admin SDK
    const serviceAccount = require(serviceAccountPath);

    return initializeApp({
        credential: cert(serviceAccount),
        databaseURL: `https://${projectId}.firebaseio.com`
    }, projectId); // <-- using projectId as instance name
}

module.exports = {
    getFirebaseAdminForProject
};

I guess I don't need to save the project in a map variable anymore, it's done internally in the firebase-admin (if I'm wrong you can comment here and explain what is being done exactly).

As you can see the solution he provide came with a very important information the "App" in the firebase-admin lib is about the admin app that is actually the project so it's very important to understand this. thank you samthecodingman!

the fix with the '!=' '!==' is also important. I can't thank you enough!


Solution

  • Based on your code, you may not be aware that the Firebase SDKs have the ability to initialize multiple app instances. When you call initializeApp without providing a name for the instance, you initialize the default instance. In your code above, you call admin.initializeApp(config) multiple times, and then try to store admin into your dictionary as the initialized project. Calling initializeApp for the same instance will throw an exception, and admin is just the SDK's namespace, it does not represent the application you just initialized (the value returned from initializeApp is, as you realized in your second attempt).

    Note: Only one instance of the Admin SDK is needed to communicate with all resources on that project, initializing multiple instances for the same project is redundant. An "app" here is not talking about iOS/Android/Web applications, but an initialized admin SDK instance.

    Taking this into consideration, you can update your code to:

    // ./firebase.js
    const { initializeApp, getApps } = require('firebase-admin/app');
    
    function getFirebaseAdminForProject(projectId) {
      if (!projectId)
        throw new Error('Project ID is required!');
    
      let projectApp = getApps().find(app => app.name === projectId);
    
      if (projectApp)
        return projectApp;
    
      // if here, project is not yet initialized
      let serviceAccountPath = process.env.FIREBASE_ACCOUNT_SERVICE_PATH != null // <- note != instead of !==
        ? `${process.env.FIREBASE_ACCOUNT_SERVICE_PATH}\\${projectId}.json`
        : `C:\\path\\to\\${projectId}.json`;
    
      return initializeApp({
        credential: admin.credential.cert(serviceAccount),
        databaseURL: `https://${projectId}.firebaseio.com`
      }, projectId); // <-- using projectId as instance name
    }
    
    exports.getFirebaseAdminForProject = getFirebaseAdminForProject;
    exports.project1App = getFirebaseAdminForProject('project-1');
    exports.project2App = getFirebaseAdminForProject('project-2');
    

    To get Firestore for a given app, you can use:

    const { getApp } = require('firebase-admin/app');
    const { getFirestore } = require('firebase-admin/firestore');
    
    const db = getFirestore(getApp('project-1'));
    

    or

    const { project1App } = require('./firebase');
    const { getFirestore } = require('firebase-admin/firestore');
    
    const db = getFirestore(project1App);
    

    Consider switching out require/exports for the modern import/export equivalents.