/* * Copyright 2000-2014 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 com.intellij.openapi.util.MultiValuesMap; 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.StringUtil; 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". * 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 = "single value"; private final String myRawValue; private final boolean myExtendedMode; private boolean modified = false; private final MultiValuesMap myDimensions; private final String mySingleValue; @NotNull private final Set myUsedDimensions = new HashSet(); @Nullable private String[] mySupportedDimensions; @NotNull private final Collection myIgnoreUnusedDimensions = new HashSet(); @NotNull private final Collection myHddenSupportedDimensions = new HashSet(); public Locator(@Nullable final String locator) throws LocatorProcessException { this(locator, 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 MultiValuesMap(); for (Map.Entry> entry : locator.myDimensions.entrySet()) { myDimensions.putAll(entry.getKey(), entry.getValue()); } mySingleValue = locator.mySingleValue; myUsedDimensions.addAll(locator.myUsedDimensions); mySupportedDimensions = mySupportedDimensions != null ? mySupportedDimensions.clone() : null; myIgnoreUnusedDimensions.addAll(locator.myIgnoreUnusedDimensions); myHddenSupportedDimensions.addAll(locator.myHddenSupportedDimensions); 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, 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, final String... supportedDimensions) throws LocatorProcessException { myRawValue = locator; myExtendedMode = extendedMode; if (StringUtil.isEmpty(locator)) { throw new LocatorProcessException("Invalid locator. Cannot be empty."); } mySupportedDimensions = supportedDimensions; @SuppressWarnings("ConstantConditions") final boolean hasDimensions = locator.contains(DIMENSION_NAME_VALUE_DELIMITER); if (!extendedMode && !hasDimensions) { mySingleValue = locator; myDimensions = new MultiValuesMap(); } else { mySingleValue = null; myDimensions = parse(locator); } } /** * Creates an empty locator with dimensions. */ private Locator() { myRawValue = ""; mySingleValue = null; myDimensions = new MultiValuesMap(); mySupportedDimensions = null; myExtendedMode = false; } public static Locator createEmptyLocator(final String... supportedDimensions) { final Locator result = new Locator(); result.mySupportedDimensions = supportedDimensions; return result; } public boolean isEmpty() { return mySingleValue == null && myDimensions.isEmpty(); } 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) { myHddenSupportedDimensions.addAll(Arrays.asList(hiddenDimensions)); } private MultiValuesMap parse(final String locator) { MultiValuesMap result = new MultiValuesMap(); 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)) { throw new LocatorProcessException(locator, parsedIndex, "Invalid dimension name :'" + currentDimensionName + "'. Should contain only alpha-numeric symbols or be known one"); } 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 = findMatchingEndDelimeterIndex(valueAndRest); if (complexValueEnd == -1) { 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); parsedIndex = parsedIndex + complexValueEnd + DIMENSION_COMPLEX_VALUE_END_DELIMITER.length(); 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 = valueAndRest.indexOf(DIMENSIONS_DELIMITER); if (valueEnd == -1) { currentDimensionValue = valueAndRest; parsedIndex = locator.length(); } else { currentDimensionValue = valueAndRest.substring(0, valueEnd); parsedIndex = parsedIndex + valueEnd + DIMENSIONS_DELIMITER.length(); } } } } result.put(currentDimensionName, currentDimensionValue); } return result; } private static int findMatchingEndDelimeterIndex(final String valueAndRest) { int pos = DIMENSION_COMPLEX_VALUE_START_DELIMITER.length(); int nesting = 1; while (nesting != 0) { final int endDelimeterPosition = valueAndRest.indexOf(DIMENSION_COMPLEX_VALUE_END_DELIMITER, pos); final int startDelimeterPosition = valueAndRest.indexOf(DIMENSION_COMPLEX_VALUE_START_DELIMITER, pos); if (endDelimeterPosition == -1) { return -1; } if (startDelimeterPosition == -1 || endDelimeterPosition < startDelimeterPosition) { nesting--; pos = endDelimeterPosition + DIMENSION_COMPLEX_VALUE_END_DELIMITER.length(); } else if (endDelimeterPosition > startDelimeterPosition) { nesting++; pos = startDelimeterPosition + DIMENSION_COMPLEX_VALUE_START_DELIMITER.length(); } } return pos - DIMENSION_COMPLEX_VALUE_END_DELIMITER.length(); } private boolean isValidName(final String name) { if ((mySupportedDimensions == null || !Arrays.asList(mySupportedDimensions).contains(name)) && !myHddenSupportedDimensions.contains(name)) { for (int i = 0; i < name.length(); i++) { if (!Character.isLetter(name.charAt(i)) && !Character.isDigit(name.charAt(i)) && !(name.charAt(i) == '-' && myExtendedMode)) return false; } } return true; } //todo: use this whenever possible public void checkLocatorFullyProcessed() { 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(); unusedDimensions.removeAll(myIgnoreUnusedDimensions); if (unusedDimensions.size() > 0) { String message; if (unusedDimensions.size() > 1) { message = "Locator dimensions " + unusedDimensions + " are ignored or unknown."; } else { if (!unusedDimensions.contains(LOCATOR_SINGLE_VALUE_UNUSED_NAME)) { message = "Locator dimension " + unusedDimensions + " is ignored or unknown."; } else { message = "Single value locator is not supported here."; } } if (mySupportedDimensions != null && mySupportedDimensions.length > 0) message += " Supported dimensions are: " + Arrays.toString(mySupportedDimensions); if (reportKindString.contains("log")) { if (reportKindString.contains("log-warn")) { LOG.warn(message); } else { LOG.debug(message); } } if (reportKindString.contains("error")) { throw new LocatorProcessException(message); } } } } private void reportKnownButNotReportedDimensions() { final Set usedDimensions = new HashSet(myUsedDimensions); if (mySupportedDimensions != null) usedDimensions.removeAll(Arrays.asList(mySupportedDimensions)); usedDimensions.removeAll(myHddenSupportedDimensions); 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 dimensions " + usedDimensions + " are actually used but not declared as such in the message to the user (" + Arrays.toString(mySupportedDimensions) + ").", 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 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) { final String value = getSingleDimensionValue(dimensionName); if (value == null) { return null; } try { return Long.parseLong(value); } catch (NumberFormatException e) { throw new LocatorProcessException("Invalid value of dimension '" + dimensionName + "': '" + value + "'. Should be a number."); } } @Nullable public Boolean getSingleDimensionValueAsBoolean(@NotNull final String dimensionName) { final String value = getSingleDimensionValue(dimensionName); if (value == null || "all".equalsIgnoreCase(value) || "any".equalsIgnoreCase(value)) { return null; } if ("true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value) || "in".equalsIgnoreCase(value)) { return true; } if ("false".equalsIgnoreCase(value) || "off".equalsIgnoreCase(value) || "no".equalsIgnoreCase(value) || "out".equalsIgnoreCase(value)) { return false; } throw new LocatorProcessException("Invalid value of dimension '" + dimensionName + "': '" + value + "'. Should be 'true', 'false' or 'any'."); } /** * @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); } /** * 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); Collection idDimension = myDimensions.get(dimensionName); if (idDimension == null || idDimension.size() == 0) { return null; } if (idDimension.size() > 1) { throw new LocatorProcessException("Only single '" + dimensionName + "' dimension is supported in locator. Found: " + idDimension); } return idDimension.iterator().next(); } public int getDimensionsCount() { return myDimensions.keySet().size(); } /** * 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) { if (isSingleValue()) { throw new LocatorProcessException("Attempt to set dimension '" + name + "' for single value locator."); } myDimensions.removeAll(name); myDimensions.put(name, value); myUsedDimensions.remove(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) { if (getSingleDimensionValue(name) == null) { setDimension(name, value); } 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("Attemt to remove dimension '" + name + "' for single value locator."); } boolean result = myDimensions.get(name) != null; myDimensions.removeAll(name); modified = true; // todo: use setDimension to replace the dimension in myRawValue return result; } /** * Provides the names of dimensions whose values were never retrieved * * @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); return result; } /** * 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)); } /** * 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 dimention specified with the passed number. * The structure of the returned locator might be diffeent 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 vaues withour brackets are supported! * @param value new value for the dimention, 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); } 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 e) { final Locator actualLocator = new Locator(locator); actualLocator.setDimension(dimensionName, value); result = actualLocator.getStringRepresentation(); } return result; } public static String setDimension(@NotNull 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 vaues withour brackets are supported! * @param value new value for the dimention, only alpha-numeric characters are supported! * @return */ public static String setDimensionIfNotPresent(@Nullable final String locator, @NotNull final String dimensionName, final String value) { if (locator == null){ return Locator.getStringLocator(dimensionName, value); } final Locator actualLocator = new Locator(locator); if (actualLocator.getSingleDimensionValue(dimensionName) == null){ return actualLocator.setDimension(dimensionName, value).getStringRepresentation(); } return locator; } 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() { if (mySingleValue != null) { return 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; } private String getValueForRendering(final String value) { if (value.contains(DIMENSIONS_DELIMITER) || value.contains(DIMENSION_NAME_VALUE_DELIMITER)) { return DIMENSION_COMPLEX_VALUE_START_DELIMITER + value + DIMENSION_COMPLEX_VALUE_END_DELIMITER; } return value; } }