com.google.gerrit.server.account.AccountResolver Maven / Gradle / Ivy
// Copyright (C) 2019 The Android Open Source Project
//
// 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.gerrit.server.account;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Suppliers;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.common.UsedAt.Project;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.index.Schema;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.config.AnonymousCowardName;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.query.account.InternalAccountQuery;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;
/**
* Helper for resolving accounts given arbitrary user-provided input.
*
* The {@code resolve*} methods each define a list of accepted formats for account resolution.
* The algorithm for resolving accounts from a list of formats is as follows:
*
*
* - For each recognized format in the order listed in the method Javadoc, check whether the
* input matches that format.
*
- If so, resolve accounts according to that format.
*
- Filter out invisible and inactive accounts.
*
- If the result list is non-empty, return.
*
- If the format is listed above as being short-circuiting, return.
*
- Otherwise, return to step 1 with the next format.
*
*
* The result never includes accounts that are not visible to the calling user. It also never
* includes inactive accounts, with a small number of specific exceptions noted in method Javadoc.
*/
@Singleton
public class AccountResolver {
public static class UnresolvableAccountException extends UnprocessableEntityException {
private static final long serialVersionUID = 1L;
private final Result result;
@VisibleForTesting
UnresolvableAccountException(Result result) {
super(exceptionMessage(result));
this.result = result;
}
public boolean isSelf() {
return result.isSelf();
}
}
public static String exceptionMessage(Result result) {
checkArgument(result.asList().size() != 1);
if (result.asList().isEmpty()) {
if (result.isSelf()) {
return "Resolving account '" + result.input() + "' requires login";
}
if (result.filteredInactive().isEmpty()) {
return "Account '" + result.input() + "' not found";
}
return result.filteredInactive().stream()
.map(a -> formatForException(result, a))
.collect(
joining(
"\n",
"Account '"
+ result.input()
+ "' only matches inactive accounts. To use an inactive account, retry with"
+ " one of the following exact account IDs:\n",
""));
}
return result.asList().stream()
.map(a -> formatForException(result, a))
.limit(3)
.collect(
joining(
"\n", "Account '" + result.input() + "' is ambiguous (at most 3 shown):\n", ""));
}
private static String formatForException(Result result, AccountState state) {
return state.account().id()
+ ": "
+ state.account().getNameEmail(result.accountResolver().anonymousCowardName);
}
public static boolean isSelf(String input) {
return "self".equals(input) || "me".equals(input);
}
public class Result {
private final String input;
private final ImmutableList list;
private final ImmutableList filteredInactive;
private final CurrentUser searchedAsUser;
@VisibleForTesting
Result(
String input,
List list,
List filteredInactive,
CurrentUser searchedAsUser) {
this.input = requireNonNull(input);
this.list = canonicalize(list);
this.filteredInactive = canonicalize(filteredInactive);
this.searchedAsUser = requireNonNull(searchedAsUser);
}
private ImmutableList canonicalize(List list) {
TreeSet set = new TreeSet<>(comparing(a -> a.account().id().get()));
set.addAll(requireNonNull(list));
return ImmutableList.copyOf(set);
}
public String input() {
return input;
}
public boolean isSelf() {
return AccountResolver.isSelf(input);
}
public ImmutableList asList() {
return list;
}
public ImmutableSet asNonEmptyIdSet() throws UnresolvableAccountException {
if (list.isEmpty()) {
throw new UnresolvableAccountException(this);
}
return asIdSet();
}
public ImmutableSet asIdSet() {
return list.stream().map(a -> a.account().id()).collect(toImmutableSet());
}
public AccountState asUnique() throws UnresolvableAccountException {
ensureUnique();
return list.get(0);
}
private void ensureUnique() throws UnresolvableAccountException {
if (list.size() != 1) {
throw new UnresolvableAccountException(this);
}
}
private void ensureSelfIsUniqueIdentifiedUser() throws UnresolvableAccountException {
ensureUnique();
if (!searchedAsUser.isIdentifiedUser()) {
throw new UnresolvableAccountException(this);
}
}
public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
if (isSelf()) {
ensureSelfIsUniqueIdentifiedUser();
// In the special case of "self", use the exact IdentifiedUser from the request context, to
// preserve the peer address and any other per-request state.
return searchedAsUser.asIdentifiedUser();
}
ensureUnique();
return userFactory.create(asUnique());
}
public IdentifiedUser asUniqueUserOnBehalfOf(CurrentUser caller)
throws UnresolvableAccountException {
ensureUnique();
if (isSelf()) {
return searchedAsUser.asIdentifiedUser();
}
return userFactory.runAs(
/* remotePeer= */ null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
}
@VisibleForTesting
ImmutableList filteredInactive() {
return filteredInactive;
}
private AccountResolver accountResolver() {
return AccountResolver.this;
}
}
@VisibleForTesting
interface Searcher {
default boolean callerShouldFilterOutInactiveCandidates() {
return true;
}
/**
* Searches can be done on behalf of either the current user or another provided user. The
* results of some searchers, such as BySelf, are affected by the context user.
*/
default boolean requiresContextUser() {
return false;
}
Optional tryParse(String input) throws IOException;
/**
* This method should be implemented for every searcher which doesn't require a context user.
*
* @param input to search for
* @return stream of the matching accounts
* @throws IOException by some subclasses
* @throws ConfigInvalidException by some subclasses
*/
default Stream search(I input) throws IOException, ConfigInvalidException {
throw new IllegalStateException("search(I) default implementation should never be called.");
}
/**
* This method should be implemented for every searcher which requires a context user.
*
* @param input to search for
* @param asUser the context user for the search
* @return stream of the matching accounts
* @throws IOException by some subclasses
* @throws ConfigInvalidException by some subclasses
*/
default Stream search(I input, CurrentUser asUser)
throws IOException, ConfigInvalidException {
if (!requiresContextUser()) {
return search(input);
}
throw new IllegalStateException(
"The searcher requires a context user, but doesn't implement search(input, asUser).");
}
boolean shortCircuitIfNoResults();
default Optional> trySearch(String input, CurrentUser asUser)
throws IOException, ConfigInvalidException {
Optional parsed = tryParse(input);
if (parsed.isEmpty()) {
return Optional.empty();
}
return requiresContextUser()
? Optional.of(search(parsed.get(), asUser))
: Optional.of(search(parsed.get()));
}
}
@VisibleForTesting
abstract static class StringSearcher implements Searcher {
@Override
public final Optional tryParse(String input) {
return matches(input) ? Optional.of(input) : Optional.empty();
}
protected abstract boolean matches(String input);
}
private abstract class AccountIdSearcher implements Searcher {
@Override
public final Stream search(Account.Id input) {
return accountCache.get(input).stream();
}
}
private static class BySelf extends StringSearcher {
@Override
public boolean callerShouldFilterOutInactiveCandidates() {
return false;
}
@Override
public boolean requiresContextUser() {
return true;
}
@Override
protected boolean matches(String input) {
return "self".equals(input) || "me".equals(input);
}
@Override
public Stream search(String input, CurrentUser asUser) {
if (!asUser.isIdentifiedUser()) {
return Stream.empty();
}
return Stream.of(asUser.asIdentifiedUser().state());
}
@Override
public boolean shortCircuitIfNoResults() {
return true;
}
}
private class ByExactAccountId extends AccountIdSearcher {
@Override
public boolean callerShouldFilterOutInactiveCandidates() {
return false;
}
@Override
public Optional tryParse(String input) {
return Account.Id.tryParse(input);
}
@Override
public boolean shortCircuitIfNoResults() {
return true;
}
}
private class ByParenthesizedAccountId extends AccountIdSearcher {
private final Pattern pattern = Pattern.compile("^.* \\(([1-9][0-9]*)\\)$");
@Override
public Optional tryParse(String input) {
Matcher m = pattern.matcher(input);
return m.matches() ? Account.Id.tryParse(m.group(1)) : Optional.empty();
}
@Override
public boolean shortCircuitIfNoResults() {
return true;
}
}
private class ByUsername extends StringSearcher {
@Override
public boolean matches(String input) {
return ExternalId.isValidUsername(input);
}
@Override
public Stream search(String input) {
return accountCache.getByUsername(input).stream();
}
@Override
public boolean shortCircuitIfNoResults() {
return false;
}
}
private class ByNameAndEmail extends StringSearcher {
@Override
protected boolean matches(String input) {
int lt = input.indexOf('<');
int gt = input.indexOf('>');
return lt >= 0 && gt > lt && input.contains("@");
}
@Override
public Stream search(String nameOrEmail) throws IOException {
// TODO(dborowitz): This would probably work as a Searcher
int lt = nameOrEmail.indexOf('<');
int gt = nameOrEmail.indexOf('>');
ImmutableSet ids = emails.getAccountFor(nameOrEmail.substring(lt + 1, gt));
ImmutableList allMatches = toAccountStates(ids).collect(toImmutableList());
if (allMatches.isEmpty() || allMatches.size() == 1) {
return allMatches.stream();
}
// More than one match. If there are any that match the full name as well, return only that
// subset. Otherwise, all are equally non-matching, so return the full set.
if (lt == 0) {
// No name was specified in the input string.
return allMatches.stream();
}
String name = nameOrEmail.substring(0, lt - 1);
ImmutableList nameMatches =
allMatches.stream()
.filter(a -> name.equals(a.account().fullName()))
.collect(toImmutableList());
return !nameMatches.isEmpty() ? nameMatches.stream() : allMatches.stream();
}
@Override
public boolean shortCircuitIfNoResults() {
return true;
}
}
private class ByEmail extends StringSearcher {
@Override
public boolean requiresContextUser() {
return true;
}
@Override
protected boolean matches(String input) {
return input.contains("@");
}
@Override
public Stream search(String input, CurrentUser asUser) throws IOException {
boolean canViewSecondaryEmails = false;
try {
if (permissionBackend.user(asUser).test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
canViewSecondaryEmails = true;
}
} catch (PermissionBackendException e) {
// remains false
}
if (canViewSecondaryEmails) {
return toAccountStates(emails.getAccountFor(input));
}
// User cannot see secondary emails, hence search by preferred email only.
List accountStates = accountQueryProvider.get().byPreferredEmail(input);
if (accountStates.size() == 1) {
return Stream.of(Iterables.getOnlyElement(accountStates));
}
if (accountStates.size() > 1) {
// An email can only belong to a single account. If multiple accounts are found it means
// there is an inconsistency, i.e. some of the found accounts have a preferred email set
// that they do not own via an external ID. Hence in this case we return only the one
// account that actually owns the email via an external ID.
for (AccountState accountState : accountStates) {
if (accountState.externalIds().stream()
.map(ExternalId::email)
.filter(Objects::nonNull)
.anyMatch(email -> email.equals(input))) {
return Stream.of(accountState);
}
}
// None of the matched accounts owns the email, return all matches to be consistent with
// the behavior of Emails.getAccountFor(String) that is used above if the user can see
// secondary emails.
return accountStates.stream();
}
// No match by preferred email. Since users can always see their own secondary emails, check
// if the input matches a secondary email of the user and if yes, return the account of the
// user.
if (asUser.isIdentifiedUser()
&& asUser.asIdentifiedUser().state().externalIds().stream()
.map(ExternalId::email)
.filter(Objects::nonNull)
.anyMatch(email -> email.equals(input))) {
return Stream.of(asUser.asIdentifiedUser().state());
}
// No match.
return Stream.empty();
}
@Override
public boolean shortCircuitIfNoResults() {
return true;
}
}
private class FromRealm extends AccountIdSearcher {
@Override
public Optional tryParse(String input) throws IOException {
return Optional.ofNullable(realm.lookup(input));
}
@Override
public boolean shortCircuitIfNoResults() {
return false;
}
}
private class ByFullName extends StringSearcher {
ByFullName() {
super();
}
@Override
protected boolean matches(String input) {
return true;
}
@Override
public Stream search(String input) {
return accountQueryProvider.get().byFullName(input).stream();
}
@Override
public boolean shortCircuitIfNoResults() {
return false;
}
}
private class ByDefaultSearch extends StringSearcher {
ByDefaultSearch() {
super();
}
@Override
public boolean requiresContextUser() {
return true;
}
@Override
protected boolean matches(String input) {
return true;
}
@Override
public Stream search(String input, CurrentUser asUser) {
// At this point we have no clue. Just perform a whole bunch of suggestions and pray we come
// up with a reasonable result list.
// TODO(dborowitz): This doesn't match the documentation; consider whether it's possible to be
// more strict here.
boolean canViewSecondaryEmails = false;
try {
if (permissionBackend.user(asUser).test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
canViewSecondaryEmails = true;
}
} catch (PermissionBackendException e) {
// remains false
}
return accountQueryProvider.get().byDefault(input, canViewSecondaryEmails).stream();
}
@Override
public boolean shortCircuitIfNoResults() {
// In practice this doesn't matter since this is the last searcher in the list, but considered
// on its own, it doesn't necessarily need to be terminal.
return false;
}
}
private final ImmutableList> nameOrEmailSearchers =
ImmutableList.of(
new ByNameAndEmail(),
new ByEmail(),
new FromRealm(),
new ByFullName(),
new ByDefaultSearch());
private final ImmutableList> searchers =
ImmutableList.>builder()
.add(new BySelf())
.add(new ByExactAccountId())
.add(new ByParenthesizedAccountId())
.add(new ByUsername())
.addAll(nameOrEmailSearchers)
.build();
private final ImmutableList> exactSearchers =
ImmutableList.>builder()
.add(new BySelf())
.add(new ByExactAccountId())
.add(new ByEmail())
.add(new ByUsername())
.build();
private final AccountCache accountCache;
private final AccountControl.Factory accountControlFactory;
private final Emails emails;
private final IdentifiedUser.GenericFactory userFactory;
private final Provider self;
private final Provider accountQueryProvider;
private final Realm realm;
private final String anonymousCowardName;
private final PermissionBackend permissionBackend;
@Inject
AccountResolver(
AccountCache accountCache,
Emails emails,
AccountControl.Factory accountControlFactory,
IdentifiedUser.GenericFactory userFactory,
Provider self,
Provider accountQueryProvider,
PermissionBackend permissionBackend,
Realm realm,
@AnonymousCowardName String anonymousCowardName) {
this.accountCache = accountCache;
this.emails = emails;
this.accountControlFactory = accountControlFactory;
this.userFactory = userFactory;
this.self = self;
this.accountQueryProvider = accountQueryProvider;
this.permissionBackend = permissionBackend;
this.realm = realm;
this.anonymousCowardName = anonymousCowardName;
}
/**
* Resolves all accounts matching the input string, visible to the current user.
*
* The following input formats are recognized:
*
*
* - The strings {@code "self"} and {@code "me"}, if the current user is an {@link
* IdentifiedUser}. In this case, may return exactly one inactive account.
*
- A bare account ID ({@code "18419"}). In this case, may return exactly one inactive
* account. This case short-circuits if the input matches.
*
- An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This
* case short-circuits if the input matches.
*
- A username ({@code "username"}).
*
- A full name and email address ({@code "Full Name
"}). This case
* short-circuits if the input matches.
* - An email address ({@code "email@example"}. This case short-circuits if the input matches.
*
- An account name recognized by the configured {@link Realm#lookup(String)} Realm}.
*
- A full name ({@code "Full Name"}).
*
- As a fallback, a {@link
* com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema,
* boolean, String) default search} against the account index.
*
*
* @param input input string.
* @return a result describing matching accounts. Never null even if the result set is empty.
* @throws ConfigInvalidException if an error occurs.
* @throws IOException if an error occurs.
*/
public Result resolve(String input) throws ConfigInvalidException, IOException {
return searchImpl(
input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive);
}
/** Resolves accounts using exact searchers. Similar to the previous method. */
@UsedAt(Project.GOOGLE)
public Result resolveExact(String input) throws ConfigInvalidException, IOException {
return searchImpl(
input,
exactSearchers,
self.get(),
this::currentUserCanSeePredicate,
AccountResolver::isActive);
}
public Result resolve(String input, Predicate accountActivityPredicate)
throws ConfigInvalidException, IOException {
return searchImpl(
input, searchers, self.get(), this::currentUserCanSeePredicate, accountActivityPredicate);
}
/**
* Resolves all accounts matching the input string, visible to the provided user.
*
* The following input formats are recognized:
*
*
* - The strings {@code "self"} and {@code "me"}, if the provided user is an {@link
* IdentifiedUser}. In this case, may return exactly one inactive account.
*
- A bare account ID ({@code "18419"}). In this case, may return exactly one inactive
* account. This case short-circuits if the input matches.
*
- An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This
* case short-circuits if the input matches.
*
- A username ({@code "username"}).
*
- A full name and email address ({@code "Full Name
"}). This case
* short-circuits if the input matches.
* - An email address ({@code "email@example"}. This case short-circuits if the input matches.
*
- An account name recognized by the configured {@link Realm#lookup(String)} Realm}.
*
- A full name ({@code "Full Name"}).
*
- As a fallback, a {@link
* com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema,
* boolean, String) default search} against the account index.
*
*
* @param asUser user to resolve the users by.
* @param input input string.
* @return a result describing matching accounts. Never null even if the result set is empty.
* @throws ConfigInvalidException if an error occurs.
* @throws IOException if an error occurs.
*/
public Result resolveAsUser(CurrentUser asUser, String input)
throws ConfigInvalidException, IOException {
return resolveAsUser(asUser, input, AccountResolver::isActive);
}
public Result resolveAsUser(
CurrentUser asUser, String input, Predicate accountActivityPredicate)
throws ConfigInvalidException, IOException {
return searchImpl(
input,
searchers,
asUser,
new ProvidedUserCanSeePredicate(asUser),
accountActivityPredicate);
}
/**
* As opposed to {@link #resolve}, the returned result includes all inactive accounts for the
* input search.
*
* This can be used to resolve Gerrit Account from email to its {@link
* com.google.gerrit.entities.Account.Id}, to make sure that if {@link Account} with such email
* exists in Gerrit (even inactive), user data (email address) won't be recorded as it is, but
* instead will be stored as a link to the corresponding Gerrit Account.
*/
public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
return searchImpl(
input,
searchers,
self.get(),
this::currentUserCanSeePredicate,
AccountResolver::allVisible);
}
public Result resolveIncludeInactiveIgnoreVisibility(String input)
throws ConfigInvalidException, IOException {
return searchImpl(
input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::allVisible);
}
public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
return searchImpl(
input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
}
@UsedAt(UsedAt.Project.PLUGIN_REVIEWERS)
public Result resolveExactIgnoreVisibility(String input)
throws ConfigInvalidException, IOException {
return searchImpl(
input, exactSearchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
}
public Result resolveAsUserIgnoreVisibility(CurrentUser asUser, String input)
throws ConfigInvalidException, IOException {
return resolveAsUserIgnoreVisibility(asUser, input, AccountResolver::isActive);
}
public Result resolveAsUserIgnoreVisibility(
CurrentUser asUser, String input, Predicate accountActivityPredicate)
throws ConfigInvalidException, IOException {
return searchImpl(
input, searchers, asUser, this::allVisiblePredicate, accountActivityPredicate);
}
/**
* Resolves all accounts matching the input string by name or email.
*
* The following input formats are recognized:
*
*
* - A full name and email address ({@code "Full Name
"}). This case
* short-circuits if the input matches.
* - An email address ({@code "email@example"}. This case short-circuits if the input matches.
*
- An account name recognized by the configured {@link Realm#lookup(String)} Realm}.
*
- A full name ({@code "Full Name"}).
*
- As a fallback, a {@link
* com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema,
* boolean, String) default search} against the account index.
*
*
* @param input input string.
* @return a result describing matching accounts. Never null even if the result set is empty.
* @throws ConfigInvalidException if an error occurs.
* @throws IOException if an error occurs.
* @deprecated for use only by MailUtil for parsing commit footers; that class needs to be
* reevaluated.
*/
@Deprecated
public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
return searchImpl(
input,
nameOrEmailSearchers,
self.get(),
this::currentUserCanSeePredicate,
AccountResolver::isActive);
}
/**
* Same as {@link #resolveByNameOrEmail(String)}, but with exact matching for the full name, email
* and full name.
*
* @param input input string.
* @return a result describing matching accounts. Never null even if the result set is empty.
* @throws ConfigInvalidException if an error occurs.
* @throws IOException if an error occurs.
* @deprecated for use only by MailUtil for parsing commit footers; that class needs to be
* reevaluated.
*/
@Deprecated
public Result resolveByExactNameOrEmail(String input) throws ConfigInvalidException, IOException {
return searchImpl(
input,
ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
self.get(),
this::currentUserCanSeePredicate,
AccountResolver::isActive);
}
private Predicate currentUserCanSeePredicate() {
return accountControlFactory.get()::canSee;
}
private class ProvidedUserCanSeePredicate implements Supplier> {
CurrentUser asUser;
ProvidedUserCanSeePredicate(CurrentUser asUser) {
this.asUser = asUser;
}
@Override
public Predicate get() {
return accountControlFactory.get(asUser)::canSee;
}
}
private Predicate allVisiblePredicate() {
return AccountResolver::allVisible;
}
/**
* @param accountState account state for which the visibility should be checked
*/
private static boolean allVisible(AccountState accountState) {
return true;
}
private static boolean isActive(AccountState accountState) {
return accountState.account().isActive();
}
@VisibleForTesting
Result searchImpl(
String input,
ImmutableList> searchers,
CurrentUser asUser,
Supplier> visibilitySupplier,
Predicate accountActivityPredicate)
throws ConfigInvalidException, IOException {
requireNonNull(asUser);
visibilitySupplier = Suppliers.memoize(visibilitySupplier::get);
List inactive = new ArrayList<>();
for (Searcher> searcher : searchers) {
Optional> maybeResults = searcher.trySearch(input, asUser);
if (!maybeResults.isPresent()) {
continue;
}
Stream results = maybeResults.get();
// Filter out non-visible results, except if it's the BySelf searcher. Since users can always
// see themselves checking the visibility is not needed for the BySelf searcher.
results = searcher instanceof BySelf ? results : results.filter(visibilitySupplier.get());
List list;
if (searcher.callerShouldFilterOutInactiveCandidates()) {
// Keep track of all inactive candidates discovered by any searchers. If we end up short-
// circuiting, the inactive list will be discarded.
List active = new ArrayList<>();
results.forEach(a -> (accountActivityPredicate.test(a) ? active : inactive).add(a));
list = active;
} else {
list = results.collect(toImmutableList());
}
if (!list.isEmpty()) {
return createResult(input, list, asUser);
}
if (searcher.shortCircuitIfNoResults()) {
// For a short-circuiting searcher, return results even if empty.
return !inactive.isEmpty()
? emptyResult(input, inactive, asUser)
: createResult(input, list, asUser);
}
}
return emptyResult(input, inactive, asUser);
}
private Result createResult(String input, List list, CurrentUser searchedAsUser) {
return new Result(input, list, ImmutableList.of(), searchedAsUser);
}
private Result emptyResult(
String input, List inactive, CurrentUser searchedAsUser) {
return new Result(input, ImmutableList.of(), inactive, searchedAsUser);
}
private Stream toAccountStates(Set ids) {
return accountCache.get(ids).values().stream();
}
}