Thursday, June 29, 2017

Firebase multi-tenancy with play framework depends on header value of HTTP request

Leave a Comment

I have a play framework project that provides APIs that shared between multiple front-ends, currently, I'm working on single front-end but I want to create a multi-tenant backend, each front-end got its own Firebase account.

My problem that I have to consider which firebase project to access depends on the request header value, that came with different values depends on the front end.

What I have now: FirebaseAppProvider.java:

public class FirebaseAppProvider implements Provider<FirebaseApp> {       private final Logger.ALogger logger;     private final Environment environment;     private final Configuration configuration;      @Inject     public FirebaseAppProvider(Environment environment, Configuration configuration) {         this.logger = Logger.of(this.getClass());         this.environment = environment;         this.configuration = configuration;     }      @Singleton     @Override     public FirebaseApp get() {         HashMap<String, String> firebaseProjects = (HashMap<String, String>) configuration.getObject("firebase");         firebaseProjects.forEach((websiteId, projectId) -> {             FileInputStream serviceAccount = null;             try {                 serviceAccount = new FileInputStream(environment.classLoader().getResource(String.format("firebase/%s.json", projectId)).getPath());             } catch (FileNotFoundException e) {                 e.printStackTrace();                 return;             }              FirebaseOptions options = new FirebaseOptions.Builder().setCredential(FirebaseCredentials.fromCertificate(serviceAccount))                     .setDatabaseUrl(String.format("https://%s.firebaseio.com/", projectId))                     .build();               FirebaseApp firebaseApp = FirebaseApp.initializeApp(options, projectId);              logger.info("FirebaseApp initialized");         });          return FirebaseApp.getInstance();     } } 

Also for Database: FirebaseDatabaseProvider.java

public class FirebaseDatabaseProvider implements Provider<FirebaseDatabase> {      private final FirebaseApp firebaseApp;     public static List<TaxItem> TAXES = new ArrayList<>();      @Inject     public FirebaseDatabaseProvider(FirebaseApp firebaseApp) {         this.firebaseApp = firebaseApp;         fetchTaxes();     }      @Singleton     @Override     public FirebaseDatabase get() {         return FirebaseDatabase.getInstance(firebaseApp);     }      @Singleton     public DatabaseReference getUserDataReference() {         return this.get().getReference("/usersData");     }      @Singleton     public DatabaseReference getTaxesConfigurationReference() {         return this.get().getReference("/appData/taxConfiguration");     }     private void fetchTaxes() {         DatabaseReference bundlesRef = getTaxesConfigurationReference().child("taxes");         bundlesRef.addValueEventListener(new ValueEventListener() {             @Override             public void onDataChange(DataSnapshot dataSnapshot) {                 TAXES.clear();                 dataSnapshot.getChildren().forEach(tax -> TAXES.add(tax.getValue(TaxItem.class)));                 Logger.info(String.format("==> %d taxes records loaded", TAXES.size()));             }              @Override             public void onCancelled(DatabaseError databaseError) {                 Logger.warn("The read failed: " + databaseError.getCode());             }         });     } } 

So I bind them as well from Module.java:

public class Module extends AbstractModule {      @Override     public void configure() {        bind(FirebaseApp.class).toProvider(FirebaseAppProvider.class).asEagerSingleton();         bind(FirebaseAuth.class).toProvider(FirebaseAuthProvider.class).asEagerSingleton();         bind(FirebaseDatabase.class).toProvider(FirebaseDatabaseProvider.class).asEagerSingleton();     }  } 

my ActionCreator:

public class ActionCreator implements play.http.ActionCreator {      @Inject     public ActionCreator() {     }      @Override     public Action createAction(Http.Request request, Method actionMethod) {         switchTenancyId(request);         return new Action.Simple() {             @Override             public CompletionStage<Result> call(Http.Context ctx) {                 return delegate.call(ctx);             }         };     }      private void switchTenancyId(Http.RequestHeader request) {         // DO something here     }      private Optional<String> getTenancyId(Http.RequestHeader request) {         String websiteId = request.getHeader("Website-ID");         System.out.println(websiteId);         return null;     } } 

What I want is when I use Database service, or auth service, I read the website id and decide which firebase project to access, I really tried the solution like this answer here: Multi tenancy with Guice Custom Scopes and Jersey

Please note I'm willing to use differents projects, not the same firebase project for each front-end.

But kinda lost, especially the request can be only accessed from controller or ActionCreator, so what I got from the question above is load providers by key into ThreadLocal and switch them for each request depends on the annotation, but I was unable to do this because of the lack of knowledge.


The minimized version of my project can be found here: https://github.com/almothafar/play-with-multi-tenant-firebase

Also, I uploaded taxes-data-export.json file to import inside firebase project for a test.

2 Answers

Answers 1

I believe Custom Scopes for this is overkill. I would recommend doing the Request-Scoped seeding from Guice's own wiki. In your case that would be something like

public class TenancyFilter implements Filter {     @Override     public void doFilter(ServletRequest request,  ServletResponse response, FilterChain chain) throws IOException, ServletException {         HttpServletRequest httpRequest = (HttpServletRequest) request;         String tenancyId = httpRequest.getHeader("YOUR-TENANCY-ID-HEADER-NAME");         httpRequest.setAttribute(                 Key.get(String.class, Names.named("tenancyId")).toString(),                 userId         );         chain.doFilter(request, response);     }      @Override     public void init(FilterConfig filterConfig) throws ServletException { }      @Override     public void destroy() { } }; 

It has to be bound in a ServletModule

public class YourModule extends ServletModule {     @Override     protected void configureServlets() {         filter("/*").through(TenancyFilter.class);     }      @Provides     @RequestScoped     @Named("tenancyId")     String provideTenancyId() {         throw new IllegalStateException("user id must be manually seeded");     } } 

Then anywhere you need to get the Tenancy ID you just inject

public class SomeClass {     private final Provider<String> tenancyIdProvider;      @Inject     SomeClass(@Named("tenancyId") Provider<String> tenancyIdProvider) {         this.tenancyIdProvider = tenancyIdProvider;     }      // Methods in request call tenancyIdProvider.get() to get and take action based on Tenancy ID. } 

Answers 2

Right, so I know Play a lot better than FireBase, but it seems to me you want to extract a tenancy ID from the request prior to feeding this into your FrieBase backend? Context when writing Java in play is Thread local, but even when doing things async you can make sure the Http.context info goes along for the ride by injecting the execution context. I would not do this via the action creator, unless you want to intercept which action is called. (Though I have a hackish solution for that as well.)

So, after a comment I'll try to elucidate here, your incoming request will be routed to a controller, like below (let me know if you need clearing up on routing etc):

Below is a solution for caching a retrieved FireBaseApp based on a "Website-ID" retrieved from the request, though I would likely put the tenancyId in the session.

import javax.inject.Inject; import java.util.concurrent.CompletionStage;  public class MyController extends Controller {     private HttpExecutionContext ec; //This is the execution-context.     private FirebaseAppProvider appProvider;     private CacheApi cache;     @Inject     public MyController(HttpExecutionContext ec, FireBaseAppProvider provider,CacheApi cache) {         this.ec = ec;         this.appProvider = provider;         this.cache = cache;      }     /**     *Retrieves a website-id from request and attempts to retrieve      *FireBaseApp object from Cache.     *If not found a new FireBaseApp will be constructed by      *FireBaseAppProvider and cached.     **/     private FireBaseApp getFireBaseApp(){      String tenancyId = request.getHeader("Website-ID);      FireBaseApp app = (FireBaseApp)cache.get(tenancyId);      if(app==null){        app=appProvider.get();        cache.put(tenancyId,app);        }      return app;     }         public CompletionStage<Result> index() {         return CompletableFuture.supplyAsync(() -> {            FireBaseApp app = getFireBaseApp();            //Do things with app.         }, ec.current()); //Used here.     } } 

Now in FireBaseAppProvider you can access the header via play.mvc.Controller, the only thing you need is to provide the HttpExecutionContext via ec.current. So (once again, I'm avoiding anything FireBase specific), in FireBaseProvider:

import play.mvc.Controller; public class FireBaseAppProvider {   public  String getWebsiteKey(){         String website = Controller.request().getHeader("Website-ID");         //If you need to handle a missing header etc, do it here.         return website;     }   public FireBaseApp get(){      String tenancyId = getWebsiteKey();      //Code to do actual construction here.   } } 

Let me know if this is close to what you're asking and I'll clean it up for you.

Also, if you want to store token validations etc, it's best to put them in the "session" of the return request, this is signed by Play Framework and allows storing data over requests. For larger data you can cache this using the session-id as part of the key.

If You Enjoyed This, Take 5 Seconds To Share It

0 comments:

Post a Comment