Repository: firebase/firebase-admin-java
Pull request #369 (2020-02-17 17:06:19)

Create TenantManager class and wire through listTenants operation.
This pull request adds the TenantManager class. The listTenants operation has been added and is wired through. I've added the relevant unit tests to FirebaseUserManagerTest (based off of the listUsers unit tests). This is part of the initiative to adding multi-tenancy support (see issue #332).


Patch:
diff --git a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java
index 2f030cc..3f13cd4 100644
--- a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java
@@ -690,8 +690,7 @@ public abstract class AbstractFirebaseAuth {
    * UpdateRequest}.
    *
    * @param request A non-null {@link UpdateRequest} instance.
-   * @return A {@link UserRecord} instance corresponding to the updated user account. account, the
-   *     task fails with a {@link FirebaseAuthException}.
+   * @return A {@link UserRecord} instance corresponding to the updated user account.
    * @throws NullPointerException if the provided update request is null.
    * @throws FirebaseAuthException if an error occurs while updating the user account.
    */
@@ -1053,7 +1052,6 @@ public abstract class AbstractFirebaseAuth {
         .callAsync(firebaseApp);
   }
 
-  @VisibleForTesting
   FirebaseUserManager getUserManager() {
     return this.userManager.get();
   }
@@ -1074,7 +1072,7 @@ public abstract class AbstractFirebaseAuth {
     };
   }
 
-  private <T> Supplier<T> threadSafeMemoize(final Supplier<T> supplier) {
+  protected <T> Supplier<T> threadSafeMemoize(final Supplier<T> supplier) {
     checkNotNull(supplier);
     return Suppliers.memoize(
         new Supplier<T>() {
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
index 47c9b3f..653c6e6 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
@@ -31,19 +31,29 @@ import com.google.firebase.internal.FirebaseService;
  * then use it to perform a variety of authentication-related operations, including generating
  * custom tokens for use by client-side code, verifying Firebase ID Tokens received from clients, or
  * creating new FirebaseApp instances that are scoped to a particular authentication UID.
- * 
- * <p>TODO(micahstairs): Add getTenantManager() method.
  */
 public class FirebaseAuth extends AbstractFirebaseAuth {
 
   private static final String SERVICE_ID = FirebaseAuth.class.getName();
 
-  private FirebaseAuth(Builder builder) {
+  private final Supplier<TenantManager> tenantManager;
+
+  private FirebaseAuth(final Builder builder) {
     super(
         builder.firebaseApp,
         builder.tokenFactory,
         builder.idTokenVerifier,
         builder.cookieVerifier);
+    tenantManager = threadSafeMemoize(new Supplier<TenantManager>() {
+      @Override
+      public TenantManager get() {
+        return new TenantManager(builder.firebaseApp, getUserManager());
+      }
+    });
+  }
+
+  public TenantManager getTenantManager() {
+    return tenantManager.get();
   }
 
   /**
diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
index 21abdea..eec3a81 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
@@ -57,9 +57,8 @@ import java.util.Map;
 /**
  * FirebaseUserManager provides methods for interacting with the Google Identity Toolkit via its
  * REST API. This class does not hold any mutable state, and is thread safe.
- * 
- * <p>TODO(micahstairs): Consider renaming this to FirebaseAuthManager since this also supports
- * tenants.
+ *
+ * <p>TODO(micahstairs): Rename this class to IdentityToolkitClient.
  *
  * @see <a href="https://developers.google.com/identity/toolkit/web/reference/relyingparty">
  *   Google Identity Toolkit</a>
@@ -227,7 +226,8 @@ class FirebaseUserManager {
     return new UserImportResult(request.getUsersCount(), response);
   }
 
-  ListTenantsResponse listTenants(int maxResults, String pageToken) throws FirebaseAuthException {
+  ListTenantsResponse listTenants(int maxResults, String pageToken)
+      throws FirebaseAuthException {
     ImmutableMap.Builder<String, Object> builder = ImmutableMap.<String, Object>builder()
         .put("pageSize", maxResults);
     if (pageToken != null) {
diff --git a/src/main/java/com/google/firebase/auth/ListTenantsPage.java b/src/main/java/com/google/firebase/auth/ListTenantsPage.java
index 5f5f213..51edb6d 100644
--- a/src/main/java/com/google/firebase/auth/ListTenantsPage.java
+++ b/src/main/java/com/google/firebase/auth/ListTenantsPage.java
@@ -32,10 +32,10 @@ import java.util.NoSuchElementException;
 
 /**
  * Represents a page of {@link Tenant} instances.
- * 
+ *
  * <p>Provides methods for iterating over the tenants in the current page, and calling up
  * subsequent pages of tenants.
- * 
+ *
  * <p>Instances of this class are thread-safe and immutable.
  */
 public class ListTenantsPage implements Page<Tenant> {
@@ -65,7 +65,7 @@ public class ListTenantsPage implements Page<Tenant> {
 
   /**
    * Returns the string token that identifies the next page.
-   * 
+   *
    * <p>Never returns null. Returns empty string if there are no more pages available to be
    * retrieved.
    *
@@ -99,7 +99,7 @@ public class ListTenantsPage implements Page<Tenant> {
   /**
    * Returns an {@link Iterable} that facilitates transparently iterating over all the tenants in
    * the current Firebase project, starting from this page.
-   * 
+   *
    * <p>The {@link Iterator} instances produced by the returned {@link Iterable} never buffers more
    * than one page of tenants at a time. It is safe to abandon the iterators (i.e. break the loops)
    * at any time.
@@ -139,7 +139,7 @@ public class ListTenantsPage implements Page<Tenant> {
 
     /**
      * An {@link Iterator} that cycles through tenants, one at a time.
-     * 
+     *
      * <p>It buffers the last retrieved batch of tenants in memory. The {@code maxResults} parameter
      * is an upper bound on the batch size.
      */
@@ -199,11 +199,9 @@ public class ListTenantsPage implements Page<Tenant> {
   static class DefaultTenantSource implements TenantSource {
 
     private final FirebaseUserManager userManager;
-    private final JsonFactory jsonFactory;
 
-    DefaultTenantSource(FirebaseUserManager userManager, JsonFactory jsonFactory) {
+    DefaultTenantSource(FirebaseUserManager userManager) {
       this.userManager = checkNotNull(userManager, "user manager must not be null");
-      this.jsonFactory = checkNotNull(jsonFactory, "json factory must not be null");
     }
 
     @Override
@@ -215,7 +213,7 @@ public class ListTenantsPage implements Page<Tenant> {
 
   /**
    * A simple factory class for {@link ListTenantsPage} instances.
-   * 
+   *
    * <p>Performs argument validation before attempting to load any tenant data (which is expensive,
    * and hence may be performed asynchronously on a separate thread).
    */
diff --git a/src/main/java/com/google/firebase/auth/TenantManager.java b/src/main/java/com/google/firebase/auth/TenantManager.java
new file mode 100644
index 0000000..62acd1b
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/TenantManager.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.auth;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.json.JsonFactory;
+import com.google.api.core.ApiFuture;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.auth.ListTenantsPage.DefaultTenantSource;
+import com.google.firebase.auth.ListTenantsPage.PageFactory;
+import com.google.firebase.auth.ListTenantsPage.TenantSource;
+import com.google.firebase.auth.Tenant.CreateRequest;
+import com.google.firebase.auth.Tenant.UpdateRequest;
+import com.google.firebase.internal.CallableOperation;
+import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.Nullable;
+
+/**
+ * This class can be used to perform a variety of tenant-related operations, including creating,
+ * updating, and listing tenants.
+ *
+ * <p>TODO(micahstairs): Implement the following methods: getAuthForTenant(), getTenant(),
+ * deleteTenant(), createTenant(), and updateTenant().
+ */
+public final class TenantManager {
+
+  private final FirebaseApp firebaseApp;
+  private final FirebaseUserManager userManager;
+
+  TenantManager(FirebaseApp firebaseApp, FirebaseUserManager userManager) {
+    this.firebaseApp = firebaseApp;
+    this.userManager = userManager;
+  }
+
+  /**
+   * Gets a page of tenants starting from the specified {@code pageToken}. Page size will be limited
+   * to 1000 tenants.
+   *
+   * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants.
+   * @return A {@link ListTenantsPage} instance.
+   * @throws IllegalArgumentException If the specified page token is empty.
+   * @throws FirebaseAuthException If an error occurs while retrieving tenant data.
+   */
+  public ListTenantsPage listTenants(@Nullable String pageToken) throws FirebaseAuthException {
+    return listTenants(pageToken, FirebaseUserManager.MAX_LIST_TENANTS_RESULTS);
+  }
+
+  /**
+   * Gets a page of tenants starting from the specified {@code pageToken}.
+   *
+   * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants.
+   * @param maxResults Maximum number of tenants to include in the returned page. This may not
+   *     exceed 1000.
+   * @return A {@link ListTenantsPage} instance.
+   * @throws IllegalArgumentException If the specified page token is empty, or max results value is
+   *     invalid.
+   * @throws FirebaseAuthException If an error occurs while retrieving tenant data.
+   */
+  public ListTenantsPage listTenants(@Nullable String pageToken, int maxResults)
+      throws FirebaseAuthException {
+    return listTenantsOp(pageToken, maxResults).call();
+  }
+
+  /**
+   * Similar to {@link #listTenants(String)} but performs the operation asynchronously.
+   *
+   * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants.
+   * @return An {@code ApiFuture} which will complete successfully with a {@link ListTenantsPage}
+   *     instance. If an error occurs while retrieving tenant data, the future throws an exception.
+   * @throws IllegalArgumentException If the specified page token is empty.
+   */
+  public ApiFuture<ListTenantsPage> listTenantsAsync(@Nullable String pageToken) {
+    return listTenantsAsync(pageToken, FirebaseUserManager.MAX_LIST_TENANTS_RESULTS);
+  }
+
+  /**
+   * Similar to {@link #listTenants(String, int)} but performs the operation asynchronously.
+   *
+   * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants.
+   * @param maxResults Maximum number of tenants to include in the returned page. This may not
+   *     exceed 1000.
+   * @return An {@code ApiFuture} which will complete successfully with a {@link ListTenantsPage}
+   *     instance. If an error occurs while retrieving tenant data, the future throws an exception.
+   * @throws IllegalArgumentException If the specified page token is empty, or max results value is
+   *     invalid.
+   */
+  public ApiFuture<ListTenantsPage> listTenantsAsync(@Nullable String pageToken, int maxResults) {
+    return listTenantsOp(pageToken, maxResults).callAsync(firebaseApp);
+  }
+
+  private CallableOperation<ListTenantsPage, FirebaseAuthException> listTenantsOp(
+      @Nullable final String pageToken, final int maxResults) {
+    // TODO(micahstairs): Add a check to make sure the app has not been destroyed yet.
+    final TenantSource tenantSource = new DefaultTenantSource(userManager);
+    final PageFactory factory = new PageFactory(tenantSource, maxResults, pageToken);
+    return new CallableOperation<ListTenantsPage, FirebaseAuthException>() {
+      @Override
+      protected ListTenantsPage execute() throws FirebaseAuthException {
+        return factory.create();
+      }
+    };
+  }
+}
diff --git a/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java
index 7abb798..4977061 100644
--- a/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java
+++ b/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java
@@ -17,13 +17,16 @@
 package com.google.firebase.auth.internal;
 
 import com.google.api.client.util.Key;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.auth.ListTenantsPage;
 import com.google.firebase.auth.Tenant;
 import java.util.List;
 
 /**
  * JSON data binding for ListTenantsResponse messages sent by Google identity toolkit service.
  */
-public class ListTenantsResponse {
+public final class ListTenantsResponse {
 
   @Key("tenants")
   private List<Tenant> tenants;
@@ -31,6 +34,7 @@ public class ListTenantsResponse {
   @Key("pageToken")
   private String pageToken;
 
+  @VisibleForTesting
   public ListTenantsResponse(List<Tenant> tenants, String pageToken) {
     this.tenants = tenants;
     this.pageToken = pageToken;
@@ -39,7 +43,7 @@ public class ListTenantsResponse {
   public ListTenantsResponse() { }
 
   public List<Tenant> getTenants() {
-    return tenants;
+    return tenants == null ? ImmutableList.<Tenant>of() : tenants;
   }
 
   public boolean hasTenants() {
@@ -47,6 +51,6 @@ public class ListTenantsResponse {
   }
 
   public String getPageToken() {
-    return pageToken;
+    return pageToken == null ? "" : pageToken;
   }
 }


Test patch:
diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
index 97ff744..fa7de0c 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
@@ -430,6 +430,52 @@ public class FirebaseUserManagerTest {
     }
   }
 
+  @Test
+  public void testListTenants() throws Exception {
+    final TestResponseInterceptor interceptor = initializeAppForUserManagement(
+        TestUtils.loadResource("listTenants.json"));
+    ListTenantsPage page =
+        FirebaseAuth.getInstance().getTenantManager().listTenantsAsync(null, 999).get();
+    ImmutableList<Tenant> tenants = ImmutableList.copyOf(page.getValues());
+    assertEquals(2, tenants.size());
+    checkTenant(tenants.get(0), "TENANT_1");
+    checkTenant(tenants.get(1), "TENANT_2");
+    assertEquals("", page.getNextPageToken());
+    checkRequestHeaders(interceptor);
+
+    GenericUrl url = interceptor.getResponse().getRequest().getUrl();
+    assertEquals(999, url.getFirst("pageSize"));
+    assertNull(url.getFirst("pageToken"));
+  }
+
+  @Test
+  public void testListTenantsWithPageToken() throws Exception {
+    final TestResponseInterceptor interceptor = initializeAppForUserManagement(
+        TestUtils.loadResource("listTenants.json"));
+    ListTenantsPage page =
+        FirebaseAuth.getInstance().getTenantManager().listTenantsAsync("token", 999).get();
+    ImmutableList<Tenant> tenants = ImmutableList.copyOf(page.getValues());
+    assertEquals(2, tenants.size());
+    checkTenant(tenants.get(0), "TENANT_1");
+    checkTenant(tenants.get(1), "TENANT_2");
+    assertEquals("", page.getNextPageToken());
+    checkRequestHeaders(interceptor);
+
+    GenericUrl url = interceptor.getResponse().getRequest().getUrl();
+    assertEquals(999, url.getFirst("pageSize"));
+    assertEquals("token", url.getFirst("pageToken"));
+  }
+
+  @Test
+  public void testListZeroTenants() throws Exception {
+    final TestResponseInterceptor interceptor = initializeAppForUserManagement("{}");
+    ListTenantsPage page =
+        FirebaseAuth.getInstance().getTenantManager().listTenantsAsync(null).get();
+    assertTrue(Iterables.isEmpty(page.getValues()));
+    assertEquals("", page.getNextPageToken());
+    checkRequestHeaders(interceptor);
+  }
+
   @Test
   public void testCreateSessionCookie() throws Exception {
     TestResponseInterceptor interceptor = initializeAppForUserManagement(
@@ -1264,6 +1310,13 @@ public class FirebaseUserManagerTest {
     assertEquals("gold", claims.get("package"));
   }
 
+  private static void checkTenant(Tenant tenant, String tenantId) {
+    assertEquals(tenantId, tenant.getTenantId());
+    assertEquals("DISPLAY_NAME", tenant.getDisplayName());
+    assertTrue(tenant.isPasswordSignInAllowed());
+    assertFalse(tenant.isEmailLinkSignInEnabled());
+  }
+
   private static void checkRequestHeaders(TestResponseInterceptor interceptor) {
     HttpHeaders headers = interceptor.getResponse().getRequest().getHeaders();
     String auth = "Bearer " + TEST_TOKEN;
@@ -1276,5 +1329,5 @@ public class FirebaseUserManagerTest {
   private interface UserManagerOp {
     void call(FirebaseAuth auth) throws Exception;
   }
-  
+
 }
diff --git a/src/test/resources/listTenants.json b/src/test/resources/listTenants.json
new file mode 100644
index 0000000..1e2f3a0
--- /dev/null
+++ b/src/test/resources/listTenants.json
@@ -0,0 +1,17 @@
+{
+  "tenants" : [ {
+    "name" : "TENANT_1",
+    "displayName" : "DISPLAY_NAME",
+    "allowPasswordSignup" : true,
+    "enableEmailLinkSignin" : false,
+    "disableAuth" : true,
+    "enableAnonymousUser" : false
+  }, {
+    "name" : "TENANT_2",
+    "displayName" : "DISPLAY_NAME",
+    "allowPasswordSignup" : true,
+    "enableEmailLinkSignin" : false,
+    "disableAuth" : true,
+    "enableAnonymousUser" : false
+  } ]
+}