// Copyright 2017 The Nomulus Authors. All Rights Reserved.
//
// 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 google.registry.tools.server;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.Maps;
import com.google.common.collect.Ordering;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import google.registry.model.ImmutableObject;
import google.registry.request.JsonResponse;
import google.registry.request.Parameter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Stream;
import javax.inject.Inject;
/**
* Abstract base class for actions that list ImmutableObjects.
*
*
Returns formatted text to be displayed on the screen.
*
* @param type of object
*/
public abstract class ListObjectsAction implements Runnable {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public static final String FIELDS_PARAM = "fields";
public static final String PRINT_HEADER_ROW_PARAM = "printHeaderRow";
public static final String FULL_FIELD_NAMES_PARAM = "fullFieldNames";
@Inject JsonResponse response;
@Inject @Parameter("fields") Optional fields;
@Inject @Parameter("printHeaderRow") Optional printHeaderRow;
@Inject @Parameter("fullFieldNames") Optional fullFieldNames;
/** Returns the set of objects to list, in the desired listing order. */
abstract ImmutableSet loadObjects();
/**
* Returns a set of fields to always include in the output as the leftmost columns. Subclasses
* can use this to specify the equivalent of a "primary key" for each object listed.
*/
ImmutableSet getPrimaryKeyFields() {
return ImmutableSet.of();
}
/**
* Returns an {@link ImmutableBiMap} that maps any field name aliases to the actual field names.
*
* Users can select aliased fields for display using either the original name or the alias. By
* default, aliased fields will use the alias name as the header instead of the original name.
*/
ImmutableBiMap getFieldAliases() {
return ImmutableBiMap.of();
}
/**
* Returns for a given {@link ImmutableObject} a mapping from field names to field values that
* will override, for any overlapping field names, the default behavior of getting the field
* value by looking up that field name in the map returned by
* {@link ImmutableObject#toDiffableFieldMap}.
*
* This can be used to specify customized printing of certain fields (e.g. to print out a
* boolean field as "active" or "-" instead of "true" or "false"). It can also be used to add
* fields to the data, e.g. for computed fields that can be accessed from the object directly but
* aren't stored as simple fields.
*/
ImmutableMap getFieldOverrides(@SuppressWarnings("unused") T object) {
return ImmutableMap.of();
}
@Override
public void run() {
try {
// Get the object data first, so we can figure out the list of all available fields using the
// data if necessary.
ImmutableSet objects = loadObjects();
logger.atInfo().log("Loaded %d objects.", objects.size());
// Get the list of fields we should return.
ImmutableSet fieldsToUse = getFieldsToUse(objects);
// Convert the data into a table.
ImmutableTable data = extractData(fieldsToUse, objects);
// Now that we have the data table, compute the column widths.
ImmutableMap columnWidths =
computeColumnWidths(data, isHeaderRowInUse(data));
// Finally, convert the table to an array of lines of text.
List lines = generateFormattedData(data, columnWidths);
// Return the results.
response.setPayload(ImmutableMap.of(
"lines", lines,
"status", "success"));
} catch (IllegalArgumentException e) {
logger.atWarning().withCause(e).log("Error while listing objects.");
// Don't return a non-200 response, since that will cause RegistryTool to barf instead of
// letting ListObjectsCommand parse the JSON response and return a clean error.
response.setPayload(
ImmutableMap.of(
"error", firstNonNull(e.getMessage(), e.getClass().getName()),
"status", "error"));
}
}
/**
* Returns the set of fields to return, aliased or not according to --full_field_names, and
* with duplicates eliminated but the ordering otherwise preserved.
*/
private ImmutableSet getFieldsToUse(ImmutableSet objects) {
// Get the list of fields from the received parameter.
List fieldsToUse;
if ((fields == null) || !fields.isPresent()) {
fieldsToUse = new ArrayList<>();
} else {
fieldsToUse = Splitter.on(',').splitToList(fields.get());
// Check whether any field name is the wildcard; if so, use all fields.
if (fieldsToUse.contains("*")) {
fieldsToUse = getAllAvailableFields(objects);
}
}
// Handle aliases according to the state of the fullFieldNames parameter.
final ImmutableMap nameMapping =
((fullFieldNames != null) && fullFieldNames.isPresent() && fullFieldNames.get())
? getFieldAliases() : getFieldAliases().inverse();
return Streams.concat(getPrimaryKeyFields().stream(), fieldsToUse.stream())
.map(field -> nameMapping.getOrDefault(field, field))
.collect(toImmutableSet());
}
/**
* Constructs a list of all available fields for use by the wildcard field specification.
* Don't include aliases, since then we'd wind up returning the same field twice.
*/
private ImmutableList getAllAvailableFields(ImmutableSet objects) {
ImmutableList.Builder fields = new ImmutableList.Builder<>();
for (T object : objects) {
// Base case of the mapping is to use ImmutableObject's toDiffableFieldMap().
fields.addAll(object.toDiffableFieldMap().keySet());
// Next, overlay any field-level overrides specified by the subclass.
fields.addAll(getFieldOverrides(object).keySet());
}
return fields.build();
}
/**
* Returns a table of data for the given sets of fields and objects. The table is row-keyed by
* object and column-keyed by field, in the same iteration order as the provided sets.
*/
private ImmutableTable
extractData(ImmutableSet fields, ImmutableSet objects) {
ImmutableTable.Builder builder = new ImmutableTable.Builder<>();
for (T object : objects) {
Map fieldMap = new HashMap<>();
// Base case of the mapping is to use ImmutableObject's toDiffableFieldMap().
fieldMap.putAll(object.toDiffableFieldMap());
// Next, overlay any field-level overrides specified by the subclass.
fieldMap.putAll(getFieldOverrides(object));
// Next, add to the mapping all the aliases, with their values defined as whatever was in the
// map under the aliased field's original name.
fieldMap.putAll(new HashMap<>(Maps.transformValues(getFieldAliases(), fieldMap::get)));
Set expectedFields = ImmutableSortedSet.copyOf(fieldMap.keySet());
for (String field : fields) {
checkArgument(fieldMap.containsKey(field),
"Field '%s' not found - recognized fields are:\n%s", field, expectedFields);
builder.put(object, field, Objects.toString(fieldMap.get(field), ""));
}
}
return builder.build();
}
/**
* Computes the column widths of the given table of strings column-keyed by strings and returns
* them as a map from column key name to integer width. The column width is defined as the max
* length of any string in that column, including the name of the column.
*/
private static ImmutableMap computeColumnWidths(
ImmutableTable, String, String> data, final boolean includingHeader) {
return ImmutableMap.copyOf(
Maps.transformEntries(
data.columnMap(),
(columnName, columnValues) ->
Streams.concat(
Stream.of(includingHeader ? columnName : ""),
columnValues.values().stream())
.map(String::length)
.max(Ordering.natural())
.get()));
}
/**
* Check whether to display headers. If the parameter is not set, print headers only if there
* is more than one column.
*/
private boolean isHeaderRowInUse(final ImmutableTable, String, String> data) {
return ((printHeaderRow != null) && printHeaderRow.isPresent())
? printHeaderRow.get() : (data.columnKeySet().size() > 1);
}
/** Converts the provided table of data to text, formatted using the provided column widths. */
private List generateFormattedData(
ImmutableTable data,
ImmutableMap columnWidths) {
Function