001/*
002 * Licensed under the Apache License, Version 2.0 (the "License");
003 * you may not use this file except in compliance with the License.
004 * You may obtain a copy of the License at
005 *
006 *     http://www.apache.org/licenses/LICENSE-2.0
007 *
008 * Unless required by applicable law or agreed to in writing, software
009 * distributed under the License is distributed on an "AS IS" BASIS,
010 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
011 * See the License for the specific language governing permissions and
012 * limitations under the License.
013 */
014package org.gbif.api.util;
015
016import org.gbif.api.model.common.search.SearchParameter;
017import org.gbif.api.model.occurrence.geo.DistanceUnit;
018import org.gbif.api.model.occurrence.search.OccurrenceSearchParameter;
019import org.gbif.api.vocabulary.Country;
020import org.gbif.api.vocabulary.Language;
021
022import java.text.ParseException;
023import java.time.LocalDate;
024import java.time.temporal.Temporal;
025import java.util.ArrayList;
026import java.util.Collection;
027import java.util.Date;
028import java.util.List;
029import java.util.Objects;
030import java.util.UUID;
031import java.util.regex.Matcher;
032import java.util.regex.Pattern;
033
034import org.apache.commons.lang3.StringUtils;
035import org.locationtech.jts.geom.Geometry;
036import org.locationtech.jts.geom.Polygon;
037import org.locationtech.jts.operation.valid.IsValidOp;
038import org.locationtech.spatial4j.context.jts.DatelineRule;
039import org.locationtech.spatial4j.context.jts.JtsSpatialContextFactory;
040import org.locationtech.spatial4j.exception.InvalidShapeException;
041import org.locationtech.spatial4j.io.WKTReader;
042import org.locationtech.spatial4j.shape.Shape;
043import org.locationtech.spatial4j.shape.jts.JtsGeometry;
044
045import static org.gbif.api.model.common.search.SearchConstants.QUERY_WILDCARD;
046import static org.gbif.api.util.IsoDateParsingUtils.SIMPLE_ISO_DATE_STR_PATTERN;
047
048/**
049 * Utility class to do basic validation of all search enum based values.
050 */
051public class SearchTypeValidator {
052
053  private static final Pattern BOOLEAN = Pattern.compile("^(true|false)$", Pattern.CASE_INSENSITIVE);
054  // this regex matches a double with an optional dot separated fracture and negative signing
055  private static final String DEC = "-?\\d+(?:\\.\\d+)?";
056
057  private static final String DECIMAL_OR_WILDCARD = "(" + DEC + "|\\*)";
058
059  private static final Range<Double> LATITUDE_RNG = Range.closed(-90.0, 90.0);
060  private static final Range<Double> LONGITUDE_RNG = Range.closed(-180.0, 180.0);
061
062  private static final String LATITUDE_ERROR_MSG = "%s is not valid value, latitude must be between -90 and 90.";
063
064  private static final String LONGITUDE_ERROR_MSG = "%s is not valid value, longitude must be between -180 and 180.";
065
066  private static final String WILD_CARD = "*";
067
068  /**
069   * Matches ranges in formats
070   *
071   * <pre>
072   * 23.1,55.2
073   * </pre>
074   *
075   * <pre>
076   * *,88
077   * </pre>
078   *
079   * and
080   *
081   * <pre>
082   * 55,*
083   * </pre>
084   *
085   * .
086   * The matcher returns 2 groups:
087   * group 1: lower bound
088   * group 2: upper bound
089   */
090  private static final Pattern DECIMAL_RANGE_PATTERN = Pattern.compile(
091    "^" + DECIMAL_OR_WILDCARD + "\\s*,\\s*" + DECIMAL_OR_WILDCARD + "$", Pattern.CASE_INSENSITIVE);
092
093  public static final String SIMPLE_ISO_YEAR_MONTH_PATTERN = "\\d{4}(?:-\\d{1,2})?";
094
095  private static final String DATE_OR_WILDCARD = "(" + SIMPLE_ISO_DATE_STR_PATTERN + "|\\*)";
096
097  private static final Pattern DATE_RANGE_PATTERN = Pattern.compile(
098    "^(" + DATE_OR_WILDCARD + "\\s*,\\s*" + DATE_OR_WILDCARD + "|" + SIMPLE_ISO_YEAR_MONTH_PATTERN + ")$", Pattern.CASE_INSENSITIVE);
099
100  /**
101   * Private default constructor.
102   */
103  private SearchTypeValidator() {
104    // empty block
105  }
106
107  /**
108   * Determines whether the value given is a valid numeric range, delimiting two values by a comma.
109   *
110   * @return true if the given value is a valid range
111   */
112  public static boolean isNumericRange(String value) {
113    if (StringUtils.isNotEmpty(value)) {
114      // decimal range for ints or doubles, or date range
115      return DECIMAL_RANGE_PATTERN.matcher(value).find();
116    }
117    return false;
118  }
119
120  /**
121   * Determines whether the value given is a valid date range or low precision (year, year-month) date, delimiting two values by a comma.
122   *
123   * @return true if the given value is a valid date range
124   */
125  public static boolean isDateRange(String value) {
126    if (StringUtils.isNotEmpty(value)) {
127      // date range
128      return DATE_RANGE_PATTERN.matcher(value).find();
129    }
130    return false;
131  }
132
133  /**
134   * Parses a range of ISO dates.
135   * The date format used is the first date format that successfully parses the lower range limit.
136   *
137   * @return the parsed range with wildcards represented as null values
138   * @throws IllegalArgumentException if value is invalid or null
139   */
140  public static Range<LocalDate> parseDateRange(String value) {
141    return IsoDateParsingUtils.parseDateRange(value);
142  }
143
144  /**
145   * Parses a decimal range in the format 123.1,456.
146   *
147   * @return the parsed range with wildcards represented as null values
148   * @throws IllegalArgumentException if value is invalid or null
149   */
150  public static Range<Double> parseDecimalRange(String value) {
151    if (StringUtils.isNotEmpty(value)) {
152      Matcher m = DECIMAL_RANGE_PATTERN.matcher(value);
153      if (m.find()) {
154        return Range.closed(parseDouble(m.group(1)), parseDouble(m.group(2)));
155      }
156    }
157    throw new IllegalArgumentException("Invalid decimal range: " + value);
158  }
159
160  /**
161   * Parses an integer range in the format 123,456
162   *
163   * @return the parsed range with wildcards represented as null values
164   * @throws IllegalArgumentException if value is invalid or null
165   */
166  public static Range<Integer> parseIntegerRange(String value) {
167    if (StringUtils.isNotEmpty(value)) {
168      Matcher m = DECIMAL_RANGE_PATTERN.matcher(value);
169      if (m.find()) {
170
171        return Range.closed(parseInteger(m.group(1)), parseInteger(m.group(2)));
172      }
173    }
174    throw new IllegalArgumentException("Invalid integer range: " + value);
175  }
176
177  /**
178   * Validates that a given parameter value matches the expected type of the parameter as defined by
179   * {@link SearchParameter#type()} and throws an IllegalArgumentException otherwise.
180   *
181   * @param param the search parameter defining the expected type
182   * @param value the parameter value to be validated
183   * @throws IllegalArgumentException if the value cannot be converted to the expected type
184   */
185  public static void validate(SearchParameter param, String value) throws IllegalArgumentException {
186    final Class<?> pType = param.type();
187
188    try {
189      if (OccurrenceSearchParameter.GEOMETRY == param) {
190        validateGeometry(value);
191      }
192
193      if (OccurrenceSearchParameter.GEO_DISTANCE == param) {
194        validateGeoDistance(value);
195      }
196
197      // All the parameters except by GEOMETRY accept the wild card value
198      if (!WILD_CARD.equalsIgnoreCase(StringUtils.trimToEmpty(value))) {
199        if (OccurrenceSearchParameter.DECIMAL_LATITUDE == param) {
200          validateLatitude(value);
201
202        } else if (OccurrenceSearchParameter.DECIMAL_LONGITUDE == param) {
203          validateLongitude(value);
204
205        } else if (UUID.class.isAssignableFrom(pType)) {
206          //noinspection ResultOfMethodCallIgnored
207          UUID.fromString(value);
208
209        } else if (Double.class.isAssignableFrom(pType)) {
210          validateDouble(value);
211
212        } else if (Integer.class.isAssignableFrom(pType)) {
213          Collection<Integer> intsFound = validateInteger(value);
214          if (OccurrenceSearchParameter.MONTH == param) {
215            validateMonth(intsFound);
216          }
217
218        } else if (Boolean.class.isAssignableFrom(pType)) {
219          // we cannot use Boolean.parseBoolean as this accepted anything as false
220          if (!BOOLEAN.matcher(value).find()) {
221            throw new IllegalArgumentException("Value " + value + " is no valid boolean");
222          }
223
224        } else if (Country.class.isAssignableFrom(pType)) {
225          // iso codes or enum name expected
226          if (Country.fromIsoCode(value) == null
227            && VocabularyUtils.lookupEnum(value, Country.class) == null) {
228            throw new NullPointerException();
229          }
230        } else if (Language.class.isAssignableFrom(pType)) {
231          // iso codes expected
232          Objects.requireNonNull(Language.fromIsoCode(value));
233        } else if (Enum.class.isAssignableFrom(pType)) {
234          // enum value expected, cast to enum
235          @SuppressWarnings("unchecked")
236          Class<? extends Enum<?>> eType = (Class<? extends Enum<?>>) pType;
237          Objects.requireNonNull(VocabularyUtils.lookupEnum(value, eType));
238
239        } else if (Date.class.isAssignableFrom(pType) || Temporal.class.isAssignableFrom(pType)) {
240          // ISO date strings
241          validateDate(value);
242
243        } else if (IsoDateInterval.class.isAssignableFrom(pType)) {
244          // ISO date strings
245          validateDate(value);
246
247        } else if (!String.class.isAssignableFrom(pType)) {
248          // any string allowed
249          // an unexpected data type - update this method!!
250          throw new IllegalArgumentException("Unknown SearchParameter data type " + pType.getCanonicalName());
251        }
252      }
253    } catch (NullPointerException e) {
254      // Objects.requireNonNull throws NPE but we want IllegalArgumentException
255      throw new IllegalArgumentException("Value " + value + " invalid for filter parameter " + param, e);
256    }
257  }
258
259  /**
260   * @return the parsed double or null for wildcards
261   * @throws NumberFormatException if invalid double
262   */
263  private static Double parseDouble(String d) {
264    if (QUERY_WILDCARD.equals(d)) {
265      return null;
266    }
267    return Double.parseDouble(d);
268  }
269
270  /**
271   * @return the parsed integer or null for wildcards
272   * @throws NumberFormatException if invalid integer
273   */
274  private static Integer parseInteger(String d) {
275    if (QUERY_WILDCARD.equals(d)) {
276      return null;
277    }
278    return Integer.parseInt(d);
279  }
280
281  /**
282   * Validates if the string value is a valid ISO 8601 format.
283   */
284  private static void validateDate(String value) {
285    if (isDateRange(value)) {
286      IsoDateParsingUtils.parseDateRange(value);
287    } else {
288      IsoDateParsingUtils.parseDate(value);
289    }
290  }
291
292  /**
293   * Validates if the value is a valid single double or a range of double values.
294   * If the value is a range each limit is validated and the wildcard character '*' is skipped.
295   */
296  private static void validateDouble(String value) {
297    if (StringUtils.isEmpty(value)) {
298      throw new IllegalArgumentException("Double cannot be null or empty");
299    }
300    try {
301      Double.parseDouble(value);
302    } catch (NumberFormatException e) {
303      parseDecimalRange(value);
304    }
305  }
306
307  /**
308   * Validates if the value is a valid single double and its value is between a range.
309   */
310  private static void validateDoubleInRange(String value, Range<Double> range, String errorMsg) {
311    if (StringUtils.isEmpty(value)) {
312      throw new IllegalArgumentException("Double cannot be null or empty");
313    }
314    try {
315      final Double doubleValue = Double.parseDouble(value);
316      if (!range.contains(doubleValue)) {
317        throw new IllegalArgumentException(String.format(errorMsg, value));
318      }
319    } catch (NumberFormatException e) {
320      if (isNumericRange(value)) {
321        Range<Double> rangeValue = parseDecimalRange(value);
322        if (!range.encloses(rangeValue)) {
323          throw new IllegalArgumentException(String.format(errorMsg, value));
324        }
325      } else {
326        throw new IllegalArgumentException("Argument is not a valid number");
327      }
328    }
329  }
330
331  /**
332   * Verify that we have indeed a wellKnownText parameter.
333   * See <a href="https://en.wikipedia.org/wiki/Well-known_text">Wikipedia</a> for basic WKT specs.
334   * The validation implemented does both syntactic and topological validation (for polygons only).
335   */
336  private static void validateGeometry(String wellKnownText) {
337    JtsSpatialContextFactory spatialContextFactory = new JtsSpatialContextFactory();
338    spatialContextFactory.normWrapLongitude = true;
339    spatialContextFactory.srid = 4326;
340    spatialContextFactory.datelineRule = DatelineRule.ccwRect;
341
342    WKTReader reader = new WKTReader(spatialContextFactory.newSpatialContext(), spatialContextFactory);
343
344    try {
345      // This validates some errors, such as a latitude > 90°
346      Shape shape = reader.parse(wellKnownText);
347
348      if (shape instanceof JtsGeometry) {
349        Geometry geometry = ((JtsGeometry) shape).getGeom();
350
351        IsValidOp validator = new IsValidOp(geometry);
352
353        if (!validator.isValid()) {
354          throw new IllegalArgumentException("Invalid geometry: " + validator.getValidationError());
355        }
356
357        if (geometry.isEmpty()) {
358          throw new IllegalArgumentException("Empty geometry: " + wellKnownText);
359        }
360
361        // Calculating the area > 0 ensures that polygons that are representing lines or points are invalidated
362        if (geometry instanceof Polygon && geometry.getArea() == 0.0) {
363          throw new IllegalArgumentException("Polygon with zero area: " + wellKnownText);
364        }
365
366        switch (geometry.getGeometryType().toUpperCase()) {
367          case "POINT":
368          case "LINESTRING":
369          case "POLYGON":
370          case "MULTIPOLYGON":
371            return;
372
373          case "MULTIPOINT":
374          case "MULTILINESTRING":
375          case "GEOMETRYCOLLECTION":
376          default:
377            throw new IllegalArgumentException("Unsupported simple WKT (unsupported type " + geometry.getGeometryType() + "): " + wellKnownText);
378        }
379      }
380    } catch (AssertionError | ParseException e) {
381      throw new IllegalArgumentException("Cannot parse simple WKT: " + wellKnownText + " " + e.getMessage());
382    } catch (InvalidShapeException e) {
383      throw new IllegalArgumentException("Invalid shape in WKT: " + wellKnownText + " " + e.getMessage());
384    }
385  }
386
387  /**
388   * Validates if the value is a valid single integer or a range of integer values.
389   * If the value is a range each limit is validated and the wildcard character '*' is skipped.
390   *
391   * @throws IllegalArgumentException if value is invalid or null
392   */
393  private static Collection<Integer> validateInteger(String value) {
394    if (StringUtils.isEmpty(value)) {
395      throw new IllegalArgumentException("Integer cannot be null or empty");
396    }
397    try {
398      List<Integer> list = new ArrayList<>();
399      list.add(Integer.parseInt(value));
400      return list;
401    } catch (NumberFormatException e) {
402      Range<Integer> range = parseIntegerRange(value);
403      List<Integer> ints = new ArrayList<>();
404      if (range.hasLowerBound()) {
405        ints.add(range.lowerEndpoint());
406      }
407      if (range.hasUpperBound()) {
408        ints.add(range.upperEndpoint());
409      }
410      return ints;
411    }
412  }
413
414  private static void validateGeoDistance(String geoDistance) {
415    if (StringUtils.isEmpty(geoDistance)) {
416      throw new IllegalArgumentException("GeoDistance cannot be null or empty");
417    }
418    String[] geoDistanceTokens = geoDistance.split(",");
419    if (geoDistanceTokens.length != 3) {
420      throw new IllegalArgumentException("GeoDistance must follow the format lat,lng,distance");
421    }
422    validateGeoDistance(geoDistanceTokens[0], geoDistanceTokens[1], geoDistanceTokens[2]);
423  }
424
425
426  public static void validateGeoDistance(String latitude, String longitude, String distance) {
427    validateLatitude(latitude);
428    validateLongitude(longitude);
429    DistanceUnit.Distance parsedDistance = DistanceUnit.parseDistance(distance);
430    if (parsedDistance.getValue() <= 0d) {
431      throw new IllegalArgumentException("GeoDistance cannot be less than zero");
432    }
433 }
434
435  /**
436   * Parses a distance range in the format 123m,456km.
437   *
438   * @return the parsed range with wildcards represented as null values
439   * @throws IllegalArgumentException if value is invalid or null
440   */
441  public static Range<DistanceUnit.Distance> parseDistanceRange(String value) {
442    if (StringUtils.isNotEmpty(value)) {
443      Matcher m = DECIMAL_RANGE_PATTERN.matcher(value);
444      if (m.find()) {
445        return Range.closed(parseDistance(m.group(1)), parseDistance(m.group(2)));
446      }
447    }
448    throw new IllegalArgumentException("Invalid distance range: " + value);
449  }
450
451  public static DistanceUnit.Distance parseDistance(String distance) {
452    DistanceUnit.Distance parsedDistance = DistanceUnit.parseDistance(distance);
453    if (parsedDistance.getValue() <= 0d) {
454      throw new IllegalArgumentException("Distance cannot be less than zero");
455    }
456    return parsedDistance;
457  }
458
459  /**
460   * Validates if the parameter is a valid latitude value.
461   */
462  private static void validateLatitude(String value) {
463    validateDoubleInRange(value, LATITUDE_RNG, LATITUDE_ERROR_MSG);
464  }
465
466  /**
467   * Validates if the parameter is a valid longitude value.
468   */
469  private static void validateLongitude(String value) {
470    validateDoubleInRange(value, LONGITUDE_RNG, LONGITUDE_ERROR_MSG);
471  }
472
473  private static void validateMonth(Collection<Integer> months) {
474    for (Integer month : months) {
475      if (month != null && (month < 1 || month > 12)) {
476        throw new IllegalArgumentException("Month needs to be between 1 - 12");
477      }
478    }
479  }
480
481}