001package org.gbif.api.util;
002
003import org.gbif.api.model.common.search.SearchParameter;
004import org.gbif.api.model.occurrence.search.OccurrenceSearchParameter;
005import org.gbif.api.vocabulary.Country;
006import org.gbif.api.vocabulary.Language;
007
008import java.time.temporal.Temporal;
009import java.util.Collection;
010import java.util.Date;
011import java.util.List;
012import java.util.UUID;
013import java.util.regex.Matcher;
014import java.util.regex.Pattern;
015
016import com.google.common.base.Preconditions;
017import com.google.common.base.Strings;
018import com.google.common.collect.ImmutableList;
019import com.google.common.collect.Lists;
020import com.google.common.collect.Range;
021import com.vividsolutions.jts.geom.Geometry;
022import com.vividsolutions.jts.geom.Polygon;
023import com.vividsolutions.jts.io.ParseException;
024import com.vividsolutions.jts.io.WKTReader;
025
026import static org.gbif.api.model.common.search.SearchConstants.QUERY_WILDCARD;
027
028/**
029 * Utility class to do basic validation of all search enum based values.
030 */
031public class SearchTypeValidator {
032
033  private static final Pattern BOOLEAN = Pattern.compile("^(true|false)$", Pattern.CASE_INSENSITIVE);
034  // this regex matches a double with an optional dot separated fracture and negative signing
035  private static final String DEC = "-?\\d+(?:\\.\\d+)?";
036  private static final String DECIMAL = "(" + DEC + ")";
037
038  private static final String DECIMAL_OR_WILDCARD = "(" + DEC + "|\\*)";
039
040  private static final Range<Double> LATITUDE_RNG = Range.closed(-90.0, 90.0);
041  private static final Range<Double> LONGITUDE_RNG = Range.closed(-180.0, 180.0);
042
043  private static final String LATITUDE_ERROR_MSG = "%s is not valid value, latitude must be between -90 and 90.";
044
045  private static final String LONGITUDE_ERROR_MSG = "%s is not valid value, longitude must be between -180 and 180.";
046
047  private static final String WILD_CARD = "*";
048
049  /**
050   * Matches ranges in formats
051   *
052   * <pre>
053   * 23.1,55.2
054   * </pre>
055   *
056   * <pre>
057   * *,88
058   * </pre>
059   *
060   * and
061   *
062   * <pre>
063   * 55,*
064   * </pre>
065   *
066   * .
067   * The matcher returns 2 groups:
068   * group 1: lower bound
069   * group 2: upper bound
070   */
071  private static final Pattern DECIMAL_RANGE_PATTERN = Pattern.compile(
072    "^" + DECIMAL_OR_WILDCARD + "\\s*,\\s*" + DECIMAL_OR_WILDCARD + "$", Pattern.CASE_INSENSITIVE);
073  /**
074   * Matches without groups a space delimited coordinate pair allowing prefixing or trailing whitespace: 10.12 -90.12
075   */
076  private static final String WKT_COORD = "\\s*" + DECIMAL + "\\s+" + DECIMAL + "\\s*";
077
078  /**
079   * Matches without groups a comma separated list of coordinates enclosed in brackets:
080   * (10.12 -90.12, 30 10, 10 20, 20 40)
081   */
082  private static final String WKT_LINE = "\\s*\\(" + WKT_COORD + "(?:," + WKT_COORD + ")*\\)\\s*";
083
084  private static final String WKT_POLYGON = "\\(" + WKT_LINE + "(?:," + WKT_LINE + ")*\\)";
085
086  private static final List<Pattern> WKT_PATTERNS = ImmutableList.of(
087    Pattern.compile("^POINT\\s*\\(" + WKT_COORD + "\\)$", Pattern.CASE_INSENSITIVE),
088    Pattern.compile("^(?:LINESTRING|LINEARRING)\\s*" + WKT_LINE + "$", Pattern.CASE_INSENSITIVE),
089    Pattern.compile("^POLYGON\\s*" + WKT_POLYGON + "$", Pattern.CASE_INSENSITIVE),
090    Pattern.compile("^MULTIPOLYGON\\s*\\(" + WKT_POLYGON + "(?:," + WKT_POLYGON + ")*\\)$", Pattern.CASE_INSENSITIVE));
091
092  /**
093   * Private default constructor.
094   */
095  private SearchTypeValidator() {
096    // empty block
097  }
098
099  /**
100   * Builds a closed range from two given values, but accepts nulls which are converted into unbound ranges
101   * in the guava instance.
102   */
103  public static <T extends Comparable<?>> Range<T> buildRange(final T lower, final T upper) {
104    if (lower == null && upper != null) {
105      return Range.atMost(upper);
106
107    } else if (lower != null && upper == null) {
108      return Range.atLeast(lower);
109
110    } else if (lower == null && upper == null) {
111      return Range.<T>all();
112
113    } else {
114      return Range.closed(lower, upper);
115    }
116  }
117
118  /**
119   * Determines whether the value given is a valid decimal or date range, delimiting two values by a comma.
120   *
121   * @return true if the given value is a valid range
122   */
123  public static boolean isRange(String value) {
124    if (!Strings.isNullOrEmpty(value)) {
125      // decimal range for ints or doubles
126      if (DECIMAL_RANGE_PATTERN.matcher(value).find()) {
127        return true;
128      }
129      // check date range
130      try {
131        IsoDateParsingUtils.parseDateRange(value);
132        return true;
133      } catch (Exception e) {
134        // aha, no range
135      }
136    }
137    return false;
138  }
139
140  /**
141   * Parses a range of ISO dates.
142   * The date format used is the first date format that successfully parses the lower range limit.
143   *
144   * @return the parsed range with wildcards represented as null values
145   * @throws IllegalArgumentException if value is invalid or null
146   */
147  public static Range<Date> parseDateRange(String value) {
148    return IsoDateParsingUtils.parseDateRange(value);
149  }
150
151  /**
152   * Parses a decimal range in the format 123.1,456.
153   *
154   * @return the parsed range with wildcards represented as null values
155   * @throws IllegalArgumentException if value is invalid or null
156   */
157  public static Range<Double> parseDecimalRange(String value) {
158    if (!Strings.isNullOrEmpty(value)) {
159      Matcher m = DECIMAL_RANGE_PATTERN.matcher(value);
160      if (m.find()) {
161        return buildRange(parseDouble(m.group(1)), parseDouble(m.group(2)));
162      }
163    }
164    throw new IllegalArgumentException("Invalid decimal range: " + value);
165  }
166
167  /**
168   * Parses an integer range in the format 123,456
169   *
170   * @return the parsed range with wildcards represented as null values
171   * @throws IllegalArgumentException if value is invalid or null
172   */
173  public static Range<Integer> parseIntegerRange(String value) {
174    if (!Strings.isNullOrEmpty(value)) {
175      Matcher m = DECIMAL_RANGE_PATTERN.matcher(value);
176      if (m.find()) {
177
178        return buildRange(parseInteger(m.group(1)), parseInteger(m.group(2)));
179      }
180    }
181    throw new IllegalArgumentException("Invalid integer range: " + value);
182  }
183
184  /**
185   * Validates that a given parameter value matches the expected type of the parameter as defined by
186   * {@link SearchParameter#type()} and throws an IllegalArgumentException otherwise.
187   *
188   * @param param the search parameter defining the expected type
189   * @param value the parameter value to be validated
190   * @throws IllegalArgumentException if the value cannot be converted to the expected type
191   */
192  public static void validate(SearchParameter param, String value) throws IllegalArgumentException {
193    final Class<?> pType = param.type();
194
195    try {
196      if (OccurrenceSearchParameter.GEOMETRY == param) {
197        validateGeometry(value);
198
199      }
200      // All the parameters except by GEOMETRY accept the wild card value
201      if (!WILD_CARD.equalsIgnoreCase(Strings.nullToEmpty(value).trim())) {
202        if (OccurrenceSearchParameter.DECIMAL_LATITUDE == param) {
203          validateLatitude(value);
204
205        } else if (OccurrenceSearchParameter.DECIMAL_LONGITUDE == param) {
206          validateLongitude(value);
207
208        } else if (UUID.class.isAssignableFrom(pType)) {
209          UUID.fromString(value);
210
211        } else if (Double.class.isAssignableFrom(pType)) {
212          validateDouble(value);
213
214        } else if (Integer.class.isAssignableFrom(pType)) {
215          Collection<Integer> intsFound = validateInteger(value);
216          if (OccurrenceSearchParameter.MONTH == param) {
217            validateMonth(intsFound);
218          }
219
220        } else if (Boolean.class.isAssignableFrom(pType)) {
221          // we cannot use Boolean.parseBoolean as this accepted anything as false
222          if (!BOOLEAN.matcher(value).find()) {
223            throw new IllegalArgumentException("Value " + value + " is no valid boolean");
224          }
225
226        } else if (Country.class.isAssignableFrom(pType)) {
227          // iso codes expected
228          Preconditions.checkNotNull(Country.fromIsoCode(value));
229
230        } else if (Language.class.isAssignableFrom(pType)) {
231          // iso codes expected
232          Preconditions.checkNotNull(Language.fromIsoCode(value));
233
234        } else if (Enum.class.isAssignableFrom(pType)) {
235          // enum value expected, cast to enum
236          @SuppressWarnings("unchecked")
237          Class<? extends Enum<?>> eType = (Class<? extends Enum<?>>) pType;
238          Preconditions.checkNotNull(VocabularyUtils.lookupEnum(value, eType));
239
240        } else if (Date.class.isAssignableFrom(pType) || Temporal.class.isAssignableFrom(pType)) {
241          // ISO date strings
242          validateDate(value);
243
244        } else if (!String.class.isAssignableFrom(pType)) {
245          // any string allowed
246          // an unexpected data type - update this method!!
247          throw new IllegalArgumentException("Unknown SearchParameter data type " + pType.getCanonicalName());
248        }
249      }
250    } catch (NullPointerException e) {
251      // Preconditions.checkNotNull throws NPE but we want IllegalArgumentException
252      throw new IllegalArgumentException("Value " + value + " invalid for filter parameter " + param, e);
253    }
254  }
255
256
257  /**
258   * @return the parsed double or null for wildcards
259   * @throws NumberFormatException if invalid double
260   */
261  private static Double parseDouble(String d) {
262    if (QUERY_WILDCARD.equals(d)) {
263      return null;
264    }
265    return Double.parseDouble(d);
266  }
267
268  /**
269   * @return the parsed integer or null for wildcards
270   * @throws NumberFormatException if invalid integer
271   */
272  private static Integer parseInteger(String d) {
273    if (QUERY_WILDCARD.equals(d)) {
274      return null;
275    }
276    return Integer.parseInt(d);
277  }
278
279  /**
280   * Validates if the string value is a valid ISO 8601 format.
281   */
282  private static void validateDate(String value) {
283    if (isRange(value)) {
284      IsoDateParsingUtils.parseDateRange(value);
285    } else {
286      IsoDateParsingUtils.parseDate(value);
287    }
288  }
289
290  /**
291   * Validates if the value is a valid single double or a range of double values.
292   * If the value is a range each limit is validated and the wildcard character '*' is skipped.
293   */
294  private static void validateDouble(String value) {
295    if (Strings.isNullOrEmpty(value)) {
296      throw new IllegalArgumentException("Double cannot be null or empty");
297    }
298    try {
299      Double.parseDouble(value);
300    } catch (NumberFormatException e) {
301      parseDecimalRange(value);
302    }
303  }
304
305
306  /**
307   * Validates if the value is a valid single double and its value is between a range.
308   */
309  private static void validateDoubleInRange(String value, Range<Double> range, String errorMsg) {
310    if (Strings.isNullOrEmpty(value)) {
311      throw new IllegalArgumentException("Double cannot be null or empty");
312    }
313    try {
314      final Double doubleValue = Double.parseDouble(value);
315      if (!range.contains(doubleValue)) {
316        throw new IllegalArgumentException(String.format(errorMsg, value));
317      }
318    } catch (NumberFormatException e) {
319      if (isRange(value)) {
320        Range<Double> rangeValue = parseDecimalRange(value);
321        if (!range.encloses(rangeValue)) {
322          throw new IllegalArgumentException(String.format(errorMsg, value));
323        }
324      } else {
325        throw new IllegalArgumentException("Argument is not a valid number");
326      }
327    }
328  }
329
330  /**
331   * Verify that we have indeed a wellKnownText parameter.
332   * See <a href="http://en.wikipedia.org/wiki/Well-known_text">wikipedia</a> for basic WKT specs.
333   * The validation implemented does both syntactic and topological validation (for polygons only): only convex polygons
334   * are accepted.
335   */
336  private static void validateGeometry(String wellKnownText) {
337    validateGeometrySyntax(wellKnownText);
338    try {
339      Geometry geometry = new WKTReader().read(wellKnownText);
340      // Calculating the area > 0 ensures that polygons that are representing lines or points are invalidated
341      if (geometry instanceof Polygon && (!geometry.isValid() || geometry.getArea() == 0.0)) {
342        throw new IllegalArgumentException("Invalid polygon " + wellKnownText);
343      }
344    } catch (ParseException e) {
345      throw new IllegalArgumentException("Invalid simple WKT: " + wellKnownText);
346    }
347  }
348
349  /**
350   * Verify that we have indeed a wellKnownText parameter.
351   * See <a href="http://en.wikipedia.org/wiki/Well-known_text">wikipedia</a> for basic WKT specs.
352   * The validation implemented does a basic syntax check for the following geometries, but does not
353   * verify that the resulting geometries are topologically valid (see the OGC SFS specification).
354   */
355  private static void validateGeometrySyntax(String wellKnownText) {
356    if (Strings.isNullOrEmpty(wellKnownText)) {
357      throw new IllegalArgumentException("Well Known Text cannot be empty or null");
358    }
359    // test all 4 supported geometry types with their respective regex - one must match!
360    for (Pattern regex : WKT_PATTERNS) {
361      if (regex.matcher(wellKnownText).find()) {
362        return;
363      }
364    }
365    throw new IllegalArgumentException("Invalid simple WKT: " + wellKnownText);
366  }
367
368  /**
369   * Validates if the value is a valid single integer or a range of integer values.
370   * If the value is a range each limit is validated and the wildcard character '*' is skipped.
371   *
372   * @throws IllegalArgumentException if value is invalid or null
373   */
374  private static Collection<Integer> validateInteger(String value) {
375    if (Strings.isNullOrEmpty(value)) {
376      throw new IllegalArgumentException("Integer cannot be null or empty");
377    }
378    try {
379      return Lists.newArrayList(Integer.parseInt(value));
380    } catch (NumberFormatException e) {
381      Range<Integer> range = parseIntegerRange(value);
382      List<Integer> ints = Lists.newArrayList();
383      if (range.hasLowerBound()) {
384        ints.add(range.lowerEndpoint());
385      }
386      if (range.hasUpperBound()) {
387        ints.add(range.upperEndpoint());
388      }
389      return ints;
390    }
391  }
392
393  /**
394   * Validates if the parameter is a valid latitude value.
395   */
396  private static void validateLatitude(String value) {
397    validateDoubleInRange(value, LATITUDE_RNG, LATITUDE_ERROR_MSG);
398  }
399
400  /**
401   * Validates if the parameter is a valid longitude value.
402   */
403  private static void validateLongitude(String value) {
404    validateDoubleInRange(value, LONGITUDE_RNG, LONGITUDE_ERROR_MSG);
405  }
406
407  private static void validateMonth(Collection<Integer> months) {
408    for (Integer month : months) {
409      if (month != null && (month < 1 || month > 12)) {
410        throw new IllegalArgumentException("Month needs to be between 1 - 12");
411      }
412    }
413  }
414
415}