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