/*
* Copyright 2000-2018 JetBrains s.r.o.
*
* 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 jetbrains.buildServer.server.rest.data;
import com.intellij.openapi.diagnostic.Logger;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import jetbrains.buildServer.server.rest.errors.LocatorProcessException;
import jetbrains.buildServer.serverSide.TeamCityProperties;
import jetbrains.buildServer.util.CollectionsUtil;
import jetbrains.buildServer.util.StringUtil;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Class that support parsing of "locators".
* Locator is a string with single value or several named "dimensions".
* text enclosed into matching parentheses "()" is excluded from parsing and the parentheses are omitted
* "$any" text (when not enclosed into parentheses) means "no value" to force dimension use, but treat the value as "null"
* Example:
* 31 - locator wth single value "31"
* name:Frodo - locator wth single dimension "name" which has value "Frodo"
* name:Frodo,age:14 - locator with two dimensions "name" which has value "Frodo" and "age", which has value "14"
* text:(Freaking symbols:,name) - locator with single dimension "text" which has value "Freaking symbols:,name"
*
* Dimension name contain only alpha-numeric symbols in usual mode. Extended mode allows in addition to use any non-empty known dimensions name which contain no ":", ",", "(", symbols)
* Dimension value should not contain symbol "," if not enclosed in "(" and ")" or
* should contain properly paired parentheses ("(" and ")") if enclosed in "(" and ")"
*
* Usual mode supports single value locators. In extended mode, those will result in single dimension with value as name and empty value.
*
* @author Yegor.Yarko
* Date: 13.08.2010
*/
public class Locator {
private static final Logger LOG = Logger.getInstance(Locator.class.getName());
private static final String DIMENSION_NAME_VALUE_DELIMITER = ":";
private static final String DIMENSIONS_DELIMITER = ",";
private static final String DIMENSION_COMPLEX_VALUE_START_DELIMITER = "(";
private static final String DIMENSION_COMPLEX_VALUE_END_DELIMITER = ")";
public static final String LOCATOR_SINGLE_VALUE_UNUSED_NAME = "$singleValue";
private static final String ANY_LITERAL = "$any";
private static final String BASE64_ESCAPE_FAKE_DIMENSION = "$base64";
public static final String HELP_DIMENSION = "$help";
private final String myRawValue;
private final boolean myExtendedMode;
private boolean modified = false;
private final LinkedHashMap> myDimensions;
private final String mySingleValue;
@NotNull private final Set myUsedDimensions;
@Nullable private String[] mySupportedDimensions;
@NotNull private final Collection myIgnoreUnusedDimensions = new HashSet();
@NotNull private final Collection myHiddenSupportedDimensions = new HashSet();
private DescriptionProvider myDescriptionProvider = null;
public Locator(@Nullable final String locator) throws LocatorProcessException {
this(locator, (String[])null);
}
/**
* Creates a new locator as a copy of the passed one preserving the entire state.
*
* @param locator
*/
public Locator(@NotNull final Locator locator) {
myRawValue = locator.myRawValue;
modified = locator.modified;
myDimensions = new LinkedHashMap>();
for (Map.Entry> entry : locator.myDimensions.entrySet()) {
myDimensions.put(entry.getKey(), new ArrayList(entry.getValue()));
}
mySingleValue = locator.mySingleValue;
myUsedDimensions = new HashSet(locator.myUsedDimensions);
mySupportedDimensions = locator.mySupportedDimensions != null ? locator.mySupportedDimensions.clone() : null;
myIgnoreUnusedDimensions.addAll(locator.myIgnoreUnusedDimensions);
myHiddenSupportedDimensions.addAll(locator.myHiddenSupportedDimensions);
myExtendedMode = locator.myExtendedMode;
}
/**
* Creates usual mode locator
*
* @param locator
* @param supportedDimensions dimensions supported in this locator, used in {@link #checkLocatorFullyProcessed()}
* @throws LocatorProcessException
*/
public Locator(@Nullable final String locator, @Nullable final String... supportedDimensions) throws LocatorProcessException {
this(locator, false, supportedDimensions);
}
/**
* Creates usual or extended mode locator
*
* @param locator
* @param supportedDimensions dimensions supported in this locator, used in {@link #checkLocatorFullyProcessed()}
* @throws LocatorProcessException
*/
public Locator(@Nullable final String locator, final boolean extendedMode, @Nullable final String... supportedDimensions) throws LocatorProcessException {
myRawValue = locator;
myExtendedMode = extendedMode;
if (StringUtil.isEmpty(locator)) {
throw new LocatorProcessException("Invalid locator. Cannot be empty.");
}
mySupportedDimensions = supportedDimensions;
myUsedDimensions = new HashSet(mySupportedDimensions == null ? 10 : Math.max(mySupportedDimensions.length, 10));
String escapedValue = getUnescapedSingleValue(locator);
if (escapedValue != null) {
mySingleValue = escapedValue;
myDimensions = new LinkedHashMap>();
} else if (!extendedMode && !hasDimensions(locator)) {
mySingleValue = locator;
myDimensions = new LinkedHashMap>();
} else {
mySingleValue = null;
myHiddenSupportedDimensions.add(HELP_DIMENSION);
myIgnoreUnusedDimensions.add(HELP_DIMENSION);
myDimensions = parse(locator, mySupportedDimensions, myHiddenSupportedDimensions, myExtendedMode);
}
}
/**
* Creates an empty locator with dimensions.
*/
private Locator(@Nullable final String... supportedDimensions) {
myRawValue = "";
mySingleValue = null;
myDimensions = new LinkedHashMap>();
mySupportedDimensions = supportedDimensions;
if (mySupportedDimensions == null) {
myUsedDimensions = new HashSet();
} else {
myUsedDimensions = new HashSet(mySupportedDimensions.length);
}
myHiddenSupportedDimensions.add(HELP_DIMENSION);
myIgnoreUnusedDimensions.add(HELP_DIMENSION);
myExtendedMode = false;
}
@Nullable
private String getUnescapedSingleValue(@NotNull final String text) {
if (text.length() > (DIMENSION_COMPLEX_VALUE_START_DELIMITER.length() + DIMENSION_COMPLEX_VALUE_END_DELIMITER.length()) &&
text.startsWith(DIMENSION_COMPLEX_VALUE_START_DELIMITER) && text.endsWith(DIMENSION_COMPLEX_VALUE_END_DELIMITER)) {
return text.substring(DIMENSION_COMPLEX_VALUE_START_DELIMITER.length(), text.length() - DIMENSION_COMPLEX_VALUE_END_DELIMITER.length());
}
return getBase64UnescapedSingleValue(text, myExtendedMode);
}
@Nullable
private static String getBase64UnescapedSingleValue(final @NotNull String text, final boolean extendedMode) {
if (!TeamCityProperties.getBooleanOrTrue("rest.locator.allowBase64")) return null;
if (!text.startsWith(BASE64_ESCAPE_FAKE_DIMENSION + DIMENSION_NAME_VALUE_DELIMITER)) {
//optimization until more then one dimension is supported
return null;
}
LinkedHashMap> parsedDimensions;
try {
parsedDimensions = parse(text, new String[]{BASE64_ESCAPE_FAKE_DIMENSION}, Collections.emptyList(), extendedMode);
} catch (LocatorProcessException e) {
return null;
}
if (parsedDimensions.size() != 1) {
//more then one dimension found
return null;
}
List base64EncodedValues = parsedDimensions.get(BASE64_ESCAPE_FAKE_DIMENSION);
if (base64EncodedValues.isEmpty()) return null;
if (base64EncodedValues.size() != 1) throw new LocatorProcessException("More then 1 " + BASE64_ESCAPE_FAKE_DIMENSION + " values, only single one is supported");
String base64EncodedValue = base64EncodedValues.get(0);
byte[] decoded;
try {
decoded = Base64.getUrlDecoder().decode(base64EncodedValue.getBytes(StandardCharsets.UTF_8));
} catch(IllegalArgumentException first){
try {
decoded = Base64.getDecoder().decode(base64EncodedValue.getBytes(StandardCharsets.UTF_8));
} catch(IllegalArgumentException second){
throw new LocatorProcessException("Invalid Base64url character sequence: '" + base64EncodedValue + "'", first);
}
}
try {
return new String(decoded, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new LocatorProcessException("Error converting decoded '" + base64EncodedValue + "' value bytes to UTF-8 string", e);
}
}
private final static String UNSAFE_CHARACTERS = DIMENSION_COMPLEX_VALUE_END_DELIMITER +
DIMENSION_COMPLEX_VALUE_START_DELIMITER +
DIMENSION_NAME_VALUE_DELIMITER +
DIMENSIONS_DELIMITER + "$";
private boolean hasDimensions(final @NotNull String locatorText) {
if (locatorText.contains(DIMENSION_NAME_VALUE_DELIMITER)) {
return true;
}
//noinspection RedundantIfStatement
if (locatorText.contains(DIMENSION_COMPLEX_VALUE_START_DELIMITER) && locatorText.contains(DIMENSION_COMPLEX_VALUE_END_DELIMITER)) {
return true;
}
return false;
}
/**
* The resultant locator will have all the dimensions from "defaults" locator set unless already defined.
* If "locator" text is empty, "defaults" locator will be used
*
* @param locator
* @param defaults
* @param supportedDimensions
* @return
*/
@NotNull
public static Locator createLocator(@Nullable final String locator, @Nullable final Locator defaults, @Nullable final String[] supportedDimensions) {
Locator result;
if (locator != null || defaults == null) {
result = new Locator(locator, supportedDimensions);
} else {
result = Locator.createEmptyLocator(supportedDimensions);
}
if (defaults != null && !result.isSingleValue()) {
for (String dimensionName : defaults.myDimensions.keySet()) {
final String value = defaults.getSingleDimensionValue(dimensionName);
if (value != null) {
result.setDimensionIfNotPresent(dimensionName, value);
if (defaults.myHiddenSupportedDimensions.contains(dimensionName)){
result.myHiddenSupportedDimensions.add(dimensionName);
}
if (defaults.myIgnoreUnusedDimensions.contains(dimensionName)){
result.myIgnoreUnusedDimensions.add(dimensionName);
}
}
}
}
return result;
}
/**
* The resultant locator will have all the dimensions and values of "mainLocator" and those from "defaultsLocator" which are note defined in "mainLocator"
* If "mainLocator" text is empty, "defaultsLocator" locator will be used
*
* @see #createLocator(String, Locator, String[])
*/
@NotNull
public static String merge(@Nullable final String mainLocator, @Nullable final String defaultsLocator) {
return createLocator(mainLocator, defaultsLocator == null ? null : new Locator(defaultsLocator), null).getStringRepresentation();
}
@NotNull
public static Locator createPotentiallyEmptyLocator(@Nullable final String locatorText) { //todo: may be support this in Locator constructor?
return StringUtil.isEmpty(locatorText) ? Locator.createEmptyLocator() : new Locator(locatorText);
}
@Nullable
@Contract("null -> null; !null -> !null")
public static Locator locator(@Nullable final String locatorText) {
return locatorText != null ? new Locator(locatorText) : null;
}
@NotNull
public static Locator createEmptyLocator(@Nullable final String... supportedDimensions) {
return new Locator(supportedDimensions);
}
public boolean isEmpty() {
return mySingleValue == null && myDimensions.isEmpty();
}
public void addSupportedDimensions(final String... dimensions) {
if (mySupportedDimensions == null) {
mySupportedDimensions = dimensions;
} else{
mySupportedDimensions = CollectionsUtil.join(Arrays.asList(mySupportedDimensions), Arrays.asList(dimensions)).toArray(mySupportedDimensions);
}
}
public void addIgnoreUnusedDimensions(final String... ignoreUnusedDimensions) {
myIgnoreUnusedDimensions.addAll(Arrays.asList(ignoreUnusedDimensions));
}
/**
* Sets dimensions which will not be reported by checkLocatorFullyProcessed method as used but not declared
*
* @param hiddenDimensions
*/
public void addHiddenDimensions(final String... hiddenDimensions) {
myHiddenSupportedDimensions.addAll(Arrays.asList(hiddenDimensions));
}
@NotNull
private static LinkedHashMap> parse(@NotNull final String locator,
@Nullable final String[] supportedDimensions, @NotNull final Collection hiddenSupportedDimensions,
final boolean extendedMode) {
LinkedHashMap> result = new LinkedHashMap>();
String currentDimensionName;
String currentDimensionValue;
int parsedIndex = 0;
while (parsedIndex < locator.length()) {
//expecting name start at parsedIndex
int nameEnd = locator.length();
String nextDelimeter = null;
int currentIndex = parsedIndex;
while (currentIndex < locator.length()) {
if (locator.startsWith(DIMENSIONS_DELIMITER, currentIndex)) {
nextDelimeter = DIMENSIONS_DELIMITER;
nameEnd = currentIndex;
break;
}
if (locator.startsWith(DIMENSION_COMPLEX_VALUE_START_DELIMITER, currentIndex)) {
nextDelimeter = DIMENSION_COMPLEX_VALUE_START_DELIMITER;
nameEnd = currentIndex;
break;
}
if (locator.startsWith(DIMENSION_NAME_VALUE_DELIMITER, currentIndex)) {
nextDelimeter = DIMENSION_NAME_VALUE_DELIMITER;
nameEnd = currentIndex;
break;
}
currentIndex++;
}
if (nameEnd == parsedIndex) {
throw new LocatorProcessException(locator, parsedIndex, "Could not find dimension name, found '" + nextDelimeter + "' instead");
}
currentDimensionName = locator.substring(parsedIndex, nameEnd);
if (!isValidName(currentDimensionName, supportedDimensions, hiddenSupportedDimensions, extendedMode)) {
throw new LocatorProcessException(locator, parsedIndex, "Invalid dimension name :'" + currentDimensionName + "'. Should contain only alpha-numeric symbols" +
(supportedDimensions == null || supportedDimensions.length == 0
? ""
: " or be known one: " + Arrays.toString(supportedDimensions)));
}
currentDimensionValue = "";
parsedIndex = nameEnd;
if (nextDelimeter != null) {
if (DIMENSIONS_DELIMITER.equals(nextDelimeter)) {
parsedIndex = nameEnd + nextDelimeter.length();
} else {
if (DIMENSION_NAME_VALUE_DELIMITER.equals(nextDelimeter)) {
parsedIndex = nameEnd + nextDelimeter.length();
}
if (DIMENSION_COMPLEX_VALUE_START_DELIMITER.equals(nextDelimeter)) {
parsedIndex = nameEnd;
}
//here begins the value at parsedIndex
final String valueAndRest = locator.substring(parsedIndex);
if (valueAndRest.startsWith(DIMENSION_COMPLEX_VALUE_START_DELIMITER)) {
//complex value detected
final int complexValueEnd = findNextOrEndOfStringConsideringBraces(valueAndRest, null);
if (complexValueEnd < 0) {
throw new LocatorProcessException(locator, parsedIndex + DIMENSION_COMPLEX_VALUE_START_DELIMITER.length(),
"Could not find matching '" + DIMENSION_COMPLEX_VALUE_END_DELIMITER + "'");
}
currentDimensionValue = valueAndRest.substring(DIMENSION_COMPLEX_VALUE_START_DELIMITER.length(), complexValueEnd - DIMENSION_COMPLEX_VALUE_END_DELIMITER.length());
parsedIndex = parsedIndex + complexValueEnd;
if (parsedIndex != locator.length()) {
if (!locator.startsWith(DIMENSIONS_DELIMITER, parsedIndex)) {
throw new LocatorProcessException(locator, parsedIndex, "No dimensions delimiter '" + DIMENSIONS_DELIMITER + "' after complex value");
} else {
parsedIndex += DIMENSIONS_DELIMITER.length();
}
}
} else {
int valueEnd = findNextOrEndOfStringConsideringBraces(valueAndRest, DIMENSIONS_DELIMITER);
if (valueEnd < 0) {
throw new LocatorProcessException(locator, parsedIndex, "Could not find matching '" + DIMENSION_COMPLEX_VALUE_END_DELIMITER + "'");
} else if (valueEnd == valueAndRest.length()) {
currentDimensionValue = valueAndRest;
parsedIndex = locator.length();
} else {
currentDimensionValue = valueAndRest.substring(0, valueEnd);
parsedIndex = parsedIndex + valueEnd + DIMENSIONS_DELIMITER.length();
}
if (ANY_LITERAL.equals(currentDimensionValue)) {
currentDimensionValue = ANY_LITERAL; //this was not a complex value, so setting exactly the same string to be able to determine this on retrieving
}
}
String unescapedValue = getBase64UnescapedSingleValue(currentDimensionValue, extendedMode);
if (unescapedValue != null) currentDimensionValue = unescapedValue;
}
}
final List currentList = result.get(currentDimensionName);
final List newList = currentList == null ? new ArrayList(1) : new ArrayList(currentList);
newList.add(currentDimensionValue);
result.put(currentDimensionName, newList);
}
return result;
}
/**
* Scans text skipping blocks wrapped in "()", returns on found stopText, after closing ")" if stopText is null or on reaching end of string
*
* @param text
* @param stopText
* @return negative value if text is not well-formed, position of a char before stopText, last char of () sequence if stopText is null or length of the text
*/
private static int findNextOrEndOfStringConsideringBraces(@NotNull final String text, @Nullable final String stopText) {
int pos = 0;
int nesting = 0;
while (pos < text.length()) {
if (text.startsWith(DIMENSION_COMPLEX_VALUE_START_DELIMITER, pos)) {
nesting++;
pos = pos + DIMENSION_COMPLEX_VALUE_START_DELIMITER.length();
} else if (text.startsWith(DIMENSION_COMPLEX_VALUE_END_DELIMITER, pos)) {
if (nesting == 0) {
//out of order ")", ignore
} else {
nesting--;
}
pos = pos + DIMENSION_COMPLEX_VALUE_END_DELIMITER.length();
if (nesting == 0 && stopText == null) return pos;
} else if (nesting == 0 && stopText != null && text.startsWith(stopText, pos)) {
return pos;
} else {
pos++;
}
}
if (nesting != 0) return -pos;
return pos;
}
private static boolean isValidName(@Nullable final String name,
final String[] supportedDimensions, @NotNull final Collection hiddenSupportedDimensions, final boolean extendedMode) {
if ((supportedDimensions == null || !Arrays.asList(supportedDimensions).contains(name)) && !hiddenSupportedDimensions.contains(name)) {
for (int i = 0; i < name.length(); i++) {
if (!Character.isLetter(name.charAt(i)) && !Character.isDigit(name.charAt(i)) && !(name.charAt(i) == '-' && extendedMode)) return false;
}
}
return true;
}
//todo: use this whenever possible
public void checkLocatorFullyProcessed() {
processHelpRequest();
String reportKindString = TeamCityProperties.getProperty("rest.report.unused.locator", "error");
if (!TeamCityProperties.getBooleanOrTrue("rest.report.locator.errors")) {
reportKindString = "off";
}
if (!reportKindString.equals("off")) {
if (reportKindString.contains("reportKnownButNotReportedDimensions")) {
reportKnownButNotReportedDimensions();
}
final Set unusedDimensions = getUnusedDimensions();
if (unusedDimensions.size() > 0) {
Set ignoredDimensions = mySupportedDimensions == null ? Collections.emptySet() :
CollectionsUtil.intersect(unusedDimensions, CollectionsUtil.join(Arrays.asList(mySupportedDimensions), myHiddenSupportedDimensions));
Set unknownDimensions = CollectionsUtil.minus(unusedDimensions, ignoredDimensions);
StringBuilder message = new StringBuilder();
if (unknownDimensions.isEmpty() && unusedDimensions.size() == myDimensions.size()) {
//nothing is used
message.append("Unsupported locator: no dimensions are used, try another combination of the dimensions.");
} else if (unusedDimensions.size() > 1) {
message.append("Locator dimensions ");
if (!ignoredDimensions.isEmpty()) {
message.append(ignoredDimensions).append(" ").append(ignoredDimensions.size() == 1 ? "is" : "are").append(" ignored");
}
if (!unknownDimensions.isEmpty()) {
if (!ignoredDimensions.isEmpty()) {
message.append(" and ");
}
message.append(unknownDimensions).append(" ").append(unknownDimensions.size() == 1 ? "is" : "are").append(" unknown");
}
message.append(".");
} else if (unusedDimensions.size() == 1) {
if (!unusedDimensions.contains(LOCATOR_SINGLE_VALUE_UNUSED_NAME)) {
if (mySupportedDimensions != null) {
message.append("Locator dimension ").append(unusedDimensions).append(" is ");
if (!ignoredDimensions.isEmpty()) {
message.append("known but was ignored during processing. Try omitting the dimension.");
} else {
message.append("unknown.");
}
} else {
message.append("Locator dimension ").append(unusedDimensions).append(" is ignored or unknown.");
}
} else {
message.append("Single value locator '").append(mySingleValue).append("' was ignored.");
}
}
if (mySupportedDimensions != null && mySupportedDimensions.length > 0) {
if (message.length() > 0)
message.append(" ");
message.append(getLocatorDescription(reportKindString.contains("includeHidden")));
}
if (reportKindString.contains("log")) {
if (reportKindString.contains("log-warn")) {
LOG.warn(message.toString());
} else {
LOG.debug(message.toString());
}
}
if (reportKindString.contains("error")) {
throw new LocatorProcessException(this, message.toString());
}
}
}
}
public boolean isHelpRequested() {
if (isSingleValue()) return HELP_DIMENSION.equals(lookupSingleValue());
return getSingleDimensionValue(HELP_DIMENSION) != null;
}
@NotNull
public Locator helpOptions() {
return createPotentiallyEmptyLocator(getSingleDimensionValue(HELP_DIMENSION));
}
public void processHelpRequest() {
if (isHelpRequested()){
throw new LocatorProcessException("Locator help requested: " + getLocatorDescription(helpOptions().getSingleDimensionValueAsStrictBoolean("hidden", false)));
}
}
public static void processHelpRequest(@Nullable final String singleValue, @NotNull final String helpMessage) {
if (HELP_DIMENSION.equals(singleValue)){
throw new LocatorProcessException("Locator help requested: " + helpMessage);
}
}
public interface DescriptionProvider{
@NotNull String get(@NotNull Locator locator, boolean includeHidden);
}
public void setDescriptionProvider(@NotNull final DescriptionProvider descriptionProvider) {
myDescriptionProvider = descriptionProvider;
}
@NotNull
public String getLocatorDescription(boolean includeHidden) {
if (myDescriptionProvider == null) {
StringBuilder result = new StringBuilder();
if (mySupportedDimensions != null) {
result.append("Supported dimensions are: [");
for (String dimension : mySupportedDimensions) {
if (!myHiddenSupportedDimensions.contains(dimension)) {
result.append(dimension).append(", ");
}
}
if (mySupportedDimensions.length > 0) result.delete(result.length() - ", ".length(), result.length());
result.append("]");
}
if (includeHidden && !myHiddenSupportedDimensions.isEmpty()) {
result.append(" Hidden supported are: [");
result.append(StringUtil.join(", ", myHiddenSupportedDimensions));
result.append("]");
}
return result.toString();
}
return myDescriptionProvider.get(this, includeHidden);
}
private void reportKnownButNotReportedDimensions() {
final Set usedDimensions = new HashSet(myUsedDimensions);
if (mySupportedDimensions != null) usedDimensions.removeAll(Arrays.asList(mySupportedDimensions));
usedDimensions.removeAll(myHiddenSupportedDimensions);
if (usedDimensions.size() > 0) {
//found used dimensions which are not declared as used.
//noinspection ThrowableInstanceNeverThrown
final Exception exception = new Exception("Helper exception to get stacktrace");
LOG.info("Locator " + StringUtil.pluralize("dimension", usedDimensions.size()) + " " + usedDimensions + (usedDimensions.size() > 1 ? " are" : " is") +
" actually used but not declared as supported. Declared locator details: " + getLocatorDescription(true), exception);
}
}
public boolean isSingleValue() {
return mySingleValue != null;
}
/**
* @return locator's not-null value if it is single-value locator, 'null' otherwise
*/
@Nullable
public String getSingleValue() {
myUsedDimensions.add(LOCATOR_SINGLE_VALUE_UNUSED_NAME);
return lookupSingleValue();
}
@Nullable
public String lookupSingleValue() {
return mySingleValue;
}
@Nullable
public Long getSingleValueAsLong() {
final String singleValue = getSingleValue();
if (singleValue == null) {
return null;
}
try {
return Long.parseLong(singleValue);
} catch (NumberFormatException e) {
throw new LocatorProcessException("Invalid single value: '" + singleValue + "'. Should be a number.");
}
}
@Nullable
public Long getSingleDimensionValueAsLong(@NotNull final String dimensionName) {
return getSingleDimensionValueAsLong(dimensionName, null);
}
@Nullable
@Contract("_, !null -> !null")
public Long getSingleDimensionValueAsLong(@NotNull final String dimensionName, @Nullable Long defaultValue) {
myUsedDimensions.add(dimensionName);
return lookupSingleDimensionValueAsLong(dimensionName, defaultValue);
}
@Nullable
@Contract("_, !null -> !null")
public Long lookupSingleDimensionValueAsLong(@NotNull final String dimensionName, @Nullable Long defaultValue) {
final String value = lookupSingleDimensionValue(dimensionName);
if (value == null) {
return defaultValue;
}
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
throw new LocatorProcessException("Invalid value of dimension '" + dimensionName + "': '" + value + "'. Should be a number.");
}
}
/**
*
* @return "null" if not defined or set to "any"
*/
@Nullable
public Boolean getSingleDimensionValueAsBoolean(@NotNull final String dimensionName) {
try {
return getBooleanByValue(getSingleDimensionValue(dimensionName));
} catch (LocatorProcessException e) {
throw new LocatorProcessException("Invalid value of dimension '" + dimensionName + "': " + e.getMessage(), e);
}
}
/**
* Same as getSingleDimensionValueAsBoolean but does not mark the dimension as used
*/
@Nullable
public Boolean lookupSingleDimensionValueAsBoolean(@NotNull final String dimensionName) {
try {
return getBooleanByValue(lookupSingleDimensionValue(dimensionName));
} catch (LocatorProcessException e) {
throw new LocatorProcessException("Invalid value of dimension '" + dimensionName + "': " + e.getMessage(), e);
}
}
public static Boolean getBooleanByValue(@Nullable final String value) {
if (value == null || "all".equalsIgnoreCase(value) || BOOLEAN_ANY.equalsIgnoreCase(value) || isAny(value)) {
return null;
}
final Boolean result = getStrictBoolean(value);
if (result != null) return result;
throw new LocatorProcessException("Invalid boolean value '" + value + "'. Should be 'true', 'false' or 'any'.");
}
public static final String BOOLEAN_TRUE = "true";
public static final String BOOLEAN_FALSE = "false";
public static final String BOOLEAN_ANY = "any";
/**
* "any" is not supported
* @return "null" if cannot be parsed as boolean
*/
@Nullable
public static Boolean getStrictBoolean(final @Nullable String value) {
if (BOOLEAN_TRUE.equalsIgnoreCase(value) || "on".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value) || "in".equalsIgnoreCase(value)) {
return true;
}
if (BOOLEAN_FALSE.equalsIgnoreCase(value) || "off".equalsIgnoreCase(value) || "no".equalsIgnoreCase(value) || "out".equalsIgnoreCase(value)) {
return false;
}
return null;
}
/**
* @param dimensionName name of the dimension
* @param defaultValue default value to use if no dimension with the name is found
* @return value specified by the dimension with name "dimensionName" (one of the possible values can be "null") or
* "defaultValue" if such dimension is not present
*/
@Nullable
public Boolean getSingleDimensionValueAsBoolean(@NotNull final String dimensionName, @Nullable Boolean defaultValue) {
final String value = getSingleDimensionValue(dimensionName);
if (value == null) {
return defaultValue;
}
return getSingleDimensionValueAsBoolean(dimensionName);
}
public boolean getSingleDimensionValueAsStrictBoolean(@NotNull final String dimensionName, boolean defaultValue) {
final String value = getSingleDimensionValue(dimensionName);
if (value == null) {
return defaultValue;
}
final Boolean result = getStrictBoolean(value);
if (result != null) return result;
throw new LocatorProcessException("Invalid strict boolean value '" + value + "'. Should be 'true' or 'false'.");
}
/**
* Extracts the single dimension value from dimensions.
*
* @param dimensionName the name of the dimension to extract value. @return 'null' if no such dimension is found, value of the dimension otherwise.
* @throws jetbrains.buildServer.server.rest.errors.LocatorProcessException if there are more then a single dimension definition for a 'dimensionName' name or the dimension has no value specified.
*/
@Nullable
public String getSingleDimensionValue(@NotNull final String dimensionName) {
myUsedDimensions.add(dimensionName);
return lookupSingleDimensionValue(dimensionName);
}
/**
* Extracts the multiple dimension value from dimensions.
*
* @param dimensionName the name of the dimension to extract value. @return empty collection if no such dimension is found, values of the dimension otherwise.
* @throws jetbrains.buildServer.server.rest.errors.LocatorProcessException if there are more then a single dimension definition for a 'dimensionName' name or the dimension has no value specified.
*/
@NotNull
public List getDimensionValue(@NotNull final String dimensionName) {
myUsedDimensions.add(dimensionName);
return lookupDimensionValue(dimensionName);
}
@NotNull
public List lookupDimensionValue(@NotNull final String dimensionName) {
Collection idDimension = myDimensions.get(dimensionName);
return idDimension != null ? new ArrayList(idDimension) : Collections.emptyList();
}
public Boolean isAnyPresent(@NotNull final String... dimensionName) {
for (String name : dimensionName) {
if (myDimensions.get(name) != null) return true;
}
return false;
}
/**
* Same as getSingleDimensionValue but does not mark the value as used
*/
@Nullable
public String lookupSingleDimensionValue(@NotNull final String dimensionName) {
Collection idDimension = myDimensions.get(dimensionName);
if (idDimension == null || idDimension.isEmpty()) {
return null;
}
if (idDimension.size() > 1) {
throw new LocatorProcessException("Only single '" + dimensionName + "' dimension is supported in locator. Found: " + idDimension);
}
final String result = idDimension.iterator().next();
//noinspection StringEquality
if (result == ANY_LITERAL) {
//if it was $any without ()-escaping as complex value, return no value
return null;
}
return result;
}
public int getDimensionsCount() {
return myDimensions.size();
}
public Collection getDefinedDimensions() {
return new ArrayList(myDimensions.keySet());
}
/**
* Replaces all the dimensions values to the one specified.
* Should be used only for multi-dimension locators.
*
* @param name name of the dimension
* @param value value of the dimension
*/
public Locator setDimension(@NotNull final String name, @NotNull final String value) {
return setDimension(name, Collections.singletonList(value));
}
/**
* Replaces all the dimensions values to those specified.
* Should be used only for multi-dimension locators.
*
* @param name name of the dimension
* @param values new values of the dimension
*/
public Locator setDimension(@NotNull final String name, @NotNull final List values) {
if (isSingleValue()) {
throw new IllegalArgumentException("Attempt to set dimension '" + name + "' for single value locator.");
}
myDimensions.put(name, new ArrayList<>(values));
markUnused(name);
modified = true; // todo: use setDimension to replace the dimension in myRawValue
return this;
}
/**
* Sets the dimension specified to the passed value if the dimension is not yet set. Does noting is the dimension already has a value.
* Should be used only for multi-dimension locators.
*
* @param name name of the dimension
* @param value value of the dimension
*/
public Locator setDimensionIfNotPresent(@NotNull final String name, @NotNull final String value) {
Collection idDimension = myDimensions.get(name);
if (idDimension == null || idDimension.isEmpty()) {
setDimension(name, value);
}
return this;
}
public Locator setDimensionIfNotPresent(@NotNull final String name, @NotNull final List values) {
Collection idDimension = myDimensions.get(name);
if (idDimension == null || idDimension.isEmpty()) {
setDimension(name, values);
}
return this;
}
/**
* Removes the dimension from the loctor. If no other dimensions are present does nothing and returns false.
* Should be used only for multi-dimension locators.
*
* @param name name of the dimension
*/
public boolean removeDimension(@NotNull final String name) {
if (isSingleValue()) {
throw new LocatorProcessException("Attempt to remove dimension '" + name + "' for single value locator.");
}
boolean result = myDimensions.get(name) != null;
myDimensions.remove(name);
modified = true; // todo: use setDimension to replace the dimension in myRawValue
return result;
}
/**
* Provides the names of dimensions whose values were never retrieved and those not marked via addIgnoreUnusedDimensions
*
* @return names of the dimensions not yet queried
*/
@NotNull
public Set getUnusedDimensions() {
Set result;
if (isSingleValue()) {
result = new HashSet(Collections.singleton(LOCATOR_SINGLE_VALUE_UNUSED_NAME));
} else {
result = new HashSet(myDimensions.keySet());
}
result.removeAll(myUsedDimensions);
result.removeAll(myIgnoreUnusedDimensions);
return result;
}
/**
* Provides the names of dimensions whose values were retrieved
*/
@NotNull
public Set getUsedDimensions() {
return new HashSet(myUsedDimensions);
}
public boolean isUnused(@NotNull final String dimensionName) {
return myDimensions.containsKey(dimensionName) && !myUsedDimensions.contains(dimensionName);
}
/**
* Marks the passed dimensions as not used.
* This also has a side effect of not reporting the dimensions as known but not reported, see "reportKnownButNotReportedDimensions" method.
*
* @param dimensionNames
*/
public void markUnused(@NotNull String... dimensionNames) {
myUsedDimensions.removeAll(Arrays.asList(dimensionNames));
}
public void markUsed(@NotNull Collection dimensionNames) {
myUsedDimensions.addAll(dimensionNames);
}
/**
* Marks all the used dimensions as not used.
* This also has a side effect of not reporting the dimensions as known but not reported, see "reportKnownButNotReportedDimensions" method.
*
* @param dimensionNames
*/
public void markAllUnused() {
myUsedDimensions.clear();
}
/**
* Returns a locator based on the supplied one replacing the numeric value of the dimension specified with the passed number.
* The structure of the returned locator might be different from the passed one, while the same dimensions and values are present.
*
* @param locator existing locator (should be valid), or null to create new locator
* @param dimensionName only alpha-numeric characters are supported! Only numeric values without brackets are supported!
* @param value new value for the dimension, only alpha-numeric characters are supported!
* @return
*/
public static String setDimension(@Nullable final String locator, @NotNull final String dimensionName, final String value) {
if (locator == null){
return Locator.getStringLocator(dimensionName, value);
}
try {
return new Locator(locator).setDimension(dimensionName, value).getStringRepresentation();
} catch (LocatorProcessException e) {
//not a valid locator... try replacing in the string, but might actually need to throw an error here
final Matcher matcher = Pattern.compile(dimensionName + DIMENSION_NAME_VALUE_DELIMITER + "\\d+").matcher(locator);
String result = matcher.replaceFirst(dimensionName + DIMENSION_NAME_VALUE_DELIMITER + value);
try {
matcher.end();
} catch (IllegalStateException ex) {
throw new LocatorProcessException("Cannot replace locator values: invalid locator '" + locator + "'");
}
return result;
}
}
public static String setDimension(@Nullable final String locator, @NotNull final String dimensionName, final long value) {
return setDimension(locator, dimensionName, String.valueOf(value));
}
/**
* Same as "setDimension" but only modifies the locator if the dimension was not present already.
*
* @param locator existing locator, should be valid!
* @param dimensionName only alpha-numeric characters are supported! Only numeric values without brackets are supported!
* @param value new value for the dimension, only alpha-numeric characters are supported!
* @return
*/
@Nullable
@Contract("_, _, !null -> !null; !null, _, _ -> !null")
public static String setDimensionIfNotPresent(@Nullable final String locator, @NotNull final String dimensionName, @Nullable final String value) {
if (value == null){
return locator;
}
if (locator == null){
return Locator.getStringLocator(dimensionName, value);
}
return (new Locator(locator)).setDimensionIfNotPresent(dimensionName, value).getStringRepresentation();
}
public static boolean isAny(@NotNull final String value) {
return ANY_LITERAL.equals(value);
}
public static String getStringLocator(final String... strings) {
final Locator result = createEmptyLocator();
if (strings.length % 2 != 0) {
throw new IllegalArgumentException("The number of parameters should be even");
}
for (int i = 0; i < strings.length; i = i + 2) {
result.setDimension(strings[i], strings[i + 1]);
}
return result.getStringRepresentation();
}
public String getStringRepresentation() { //todo: what is returned for empty locator??? and replace "actualLocator.isEmpty() ? null : actualLocator.getStringRepresentation()"
if (mySingleValue != null) {
return getValueForRendering(mySingleValue);
}
if (!modified) {
return myRawValue;
}
String result = "";
for (Map.Entry> dimensionEntries : myDimensions.entrySet()) {
for (String value : dimensionEntries.getValue()) {
if (!StringUtil.isEmpty(result)) {
result += DIMENSIONS_DELIMITER;
}
result += dimensionEntries.getKey() + DIMENSION_NAME_VALUE_DELIMITER + getValueForRendering(value);
}
}
return result;
}
@NotNull
public static String getValueForRendering(@NotNull final String value) {
LevelData nestingData = getNestingData(value);
if (nestingData.getCurrentLevel() != 0 || nestingData.getMinLevel() < 0) {
return DIMENSION_COMPLEX_VALUE_START_DELIMITER
+ BASE64_ESCAPE_FAKE_DIMENSION + DIMENSION_NAME_VALUE_DELIMITER + new String(Base64.getUrlEncoder().encode(value.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)
+ DIMENSION_COMPLEX_VALUE_END_DELIMITER;
}
if (nestingData.getMaxLevel() > 0 || value.contains(DIMENSIONS_DELIMITER) || value.contains(DIMENSION_NAME_VALUE_DELIMITER)) {
// this also covers case of (value.startsWith(DIMENSION_COMPLEX_VALUE_START_DELIMITER) && value.endsWith(DIMENSION_COMPLEX_VALUE_END_DELIMITER))
return DIMENSION_COMPLEX_VALUE_START_DELIMITER + value + DIMENSION_COMPLEX_VALUE_END_DELIMITER;
}
return value;
}
private static LevelData getNestingData(@NotNull final String value) {
LevelData data = new LevelData();
for (int index = 0; index < value.length(); index++) {
data.process(value.charAt(index));
}
return data;
}
private static class LevelData {
private int myMaxLevel = 0;
private int myMinLevel = 0;
private int myCurrentLevel = 0;
void process(char ch) {
switch (ch) {
case '(': //DIMENSION_COMPLEX_VALUE_START_DELIMITER
myCurrentLevel++;
if (myMaxLevel < myCurrentLevel) myMaxLevel = myCurrentLevel;
break;
case ')': //DIMENSION_COMPLEX_VALUE_END_DELIMITER
myCurrentLevel--;
if (myMinLevel > myCurrentLevel) myMinLevel = myCurrentLevel;
break;
}
}
public int getMaxLevel() {
return myMaxLevel;
}
public int getMinLevel() {
return myMinLevel;
}
public int getCurrentLevel() {
return myCurrentLevel;
}
}
@Override
public String toString() {
return getStringRepresentation();
}
}