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          }
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 or enum name expected
228          if (Country.fromIsoCode(value) == null
229            && VocabularyUtils.lookupEnum(value, Country.class) == null) {
230            throw new NullPointerException();
231          }
232        } else if (Language.class.isAssignableFrom(pType)) {
233          // iso codes expected
234          Objects.requireNonNull(Language.fromIsoCode(value));
235        } else if (Enum.class.isAssignableFrom(pType)) {
236          // enum value expected, cast to enum
237          @SuppressWarnings("unchecked")
238          Class<? extends Enum<?>> eType = (Class<? extends Enum<?>>) pType;
239          Objects.requireNonNull(VocabularyUtils.lookupEnum(value, eType));
240
241        } else if (Date.class.isAssignableFrom(pType) || Temporal.class.isAssignableFrom(pType)) {
242          // ISO date strings
243          validateDate(value);
244
245        } else if (IsoDateInterval.class.isAssignableFrom(pType)) {
246          // ISO date strings
247          validateDate(value);
248
249        } else if (!String.class.isAssignableFrom(pType)) {
250          // any string allowed
251          // an unexpected data type - update this method!!
252          throw new IllegalArgumentException("Unknown SearchParameter data type " + pType.getCanonicalName());
253        }
254      }
255    } catch (NullPointerException e) {
256      // Objects.requireNonNull throws NPE but we want IllegalArgumentException
257      throw new IllegalArgumentException("Value " + value + " invalid for filter parameter " + param, e);
258    }
259  }
260
261  /**
262   * @return the parsed double or null for wildcards
263   * @throws NumberFormatException if invalid double
264   */
265  private static Double parseDouble(String d) {
266    if (QUERY_WILDCARD.equals(d)) {
267      return null;
268    }
269    return Double.parseDouble(d);
270  }
271
272  /**
273   * @return the parsed integer or null for wildcards
274   * @throws NumberFormatException if invalid integer
275   */
276  private static Integer parseInteger(String d) {
277    if (QUERY_WILDCARD.equals(d)) {
278      return null;
279    }
280    return Integer.parseInt(d);
281  }
282
283  /**
284   * Validates if the string value is a valid ISO 8601 format.
285   */
286  private static void validateDate(String value) {
287    if (isDateRange(value)) {
288      IsoDateParsingUtils.parseDateRange(value);
289    } else {
290      IsoDateParsingUtils.parseDate(value);
291    }
292  }
293
294  /**
295   * Validates if the value is a valid single double or a range of double values.
296   * If the value is a range each limit is validated and the wildcard character '*' is skipped.
297   */
298  private static void validateDouble(String value) {
299    if (StringUtils.isEmpty(value)) {
300      throw new IllegalArgumentException("Double cannot be null or empty");
301    }
302    try {
303      Double.parseDouble(value);
304    } catch (NumberFormatException e) {
305      parseDecimalRange(value);
306    }
307  }
308
309  /**
310   * Validates if the value is a valid single double and its value is between a range.
311   */
312  private static void validateDoubleInRange(String value, Range<Double> range, String errorMsg) {
313    if (StringUtils.isEmpty(value)) {
314      throw new IllegalArgumentException("Double cannot be null or empty");
315    }
316    try {
317      final Double doubleValue = Double.parseDouble(value);
318      if (!range.contains(doubleValue)) {
319        throw new IllegalArgumentException(String.format(errorMsg, value));
320      }
321    } catch (NumberFormatException e) {
322      if (isNumericRange(value)) {
323        Range<Double> rangeValue = parseDecimalRange(value);
324        if (!range.encloses(rangeValue)) {
325          throw new IllegalArgumentException(String.format(errorMsg, value));
326        }
327      } else {
328        throw new IllegalArgumentException("Argument is not a valid number");
329      }
330    }
331  }
332
333  /**
334   * Verify that we have indeed a wellKnownText parameter.
335   * See <a href="https://en.wikipedia.org/wiki/Well-known_text">Wikipedia</a> for basic WKT specs.
336   * The validation implemented does both syntactic and topological validation (for polygons only).
337   */
338  private static void validateGeometry(String wellKnownText) {
339    JtsSpatialContextFactory spatialContextFactory = new JtsSpatialContextFactory();
340    spatialContextFactory.normWrapLongitude = true;
341    spatialContextFactory.srid = 4326;
342    spatialContextFactory.datelineRule = DatelineRule.ccwRect;
343
344    WKTReader reader = new WKTReader(spatialContextFactory.newSpatialContext(), spatialContextFactory);
345
346    try {
347      // This validates some errors, such as a latitude > 90°
348      Shape shape = reader.parse(wellKnownText);
349
350      if (shape instanceof JtsGeometry) {
351        Geometry geometry = ((JtsGeometry) shape).getGeom();
352
353        IsValidOp validator = new IsValidOp(geometry);
354
355        if (!validator.isValid()) {
356          throw new IllegalArgumentException("Invalid geometry: " + validator.getValidationError());
357        }
358
359        if (geometry.isEmpty()) {
360          throw new IllegalArgumentException("Empty geometry: " + wellKnownText);
361        }
362
363        // Validate a polygon — check the winding order
364        if (geometry instanceof Polygon) {
365          validatePolygon((Polygon) geometry, wellKnownText);
366        }
367
368        // Validate polygons within a multipolygon
369        if (geometry instanceof MultiPolygon) {
370          MultiPolygon multiPolygon = (MultiPolygon) geometry;
371          for (int p = 0; p < multiPolygon.getNumGeometries(); p++) {
372            validatePolygon((Polygon) multiPolygon.getGeometryN(p), wellKnownText);
373          }
374        }
375
376        switch (geometry.getGeometryType().toUpperCase()) {
377          case "POINT":
378          case "LINESTRING":
379          case "POLYGON":
380          case "MULTIPOLYGON":
381            return;
382
383          case "MULTIPOINT":
384          case "MULTILINESTRING":
385          case "GEOMETRYCOLLECTION":
386          default:
387            throw new IllegalArgumentException("Unsupported simple WKT (unsupported type " + geometry.getGeometryType() + "): " + wellKnownText);
388        }
389      }
390    } catch (AssertionError | ParseException e) {
391      throw new IllegalArgumentException("Cannot parse simple WKT: " + wellKnownText + " " + e.getMessage());
392    } catch (InvalidShapeException e) {
393      throw new IllegalArgumentException("Invalid shape in WKT: " + wellKnownText + " " + e.getMessage());
394    }
395  }
396
397  private static void validatePolygon(Polygon polygon, String wellKnownText) {
398    // Calculating the area > 0 ensures that polygons that are representing lines or points are invalidated
399    if (polygon.getArea() == 0.0) {
400      throw new IllegalArgumentException("Polygon with zero area: " + polygon.toText());
401    }
402
403    // Exterior ring must be anticlockwise
404    boolean isCCW = Orientation.isCCW(polygon.getExteriorRing().getCoordinates());
405    if (!isCCW) {
406      String reversedText = polygon.getExteriorRing().reverse().toText();
407      throw new IllegalArgumentException("Polygon with clockwise exterior ring: " + wellKnownText +
408        ". Did you mean these coordinates?  (Note this is only part of the polygon or multipolygon you provided.) " +
409        reversedText);
410    }
411
412    // Interior rings must be clockwise
413    for (int r = 0; r < polygon.getNumInteriorRing(); r++) {
414      isCCW = Orientation.isCCW(polygon.getInteriorRingN(r).getCoordinates());
415      if (isCCW) {
416        String reversedText = polygon.getInteriorRingN(r).reverse().toText();
417        throw new IllegalArgumentException("Polygon with anticlockwise interior ring: " + wellKnownText +
418          ". Did you mean these coordinates? (Note this is only part of the polygon or multipolygon you provided.) " +
419          reversedText);
420      }
421    }
422  }
423
424  /**
425   * Validates if the value is a valid single integer or a range of integer values.
426   * If the value is a range each limit is validated and the wildcard character '*' is skipped.
427   *
428   * @throws IllegalArgumentException if value is invalid or null
429   */
430  private static Collection<Integer> validateInteger(String value) {
431    if (StringUtils.isEmpty(value)) {
432      throw new IllegalArgumentException("Integer cannot be null or empty");
433    }
434    try {
435      List<Integer> list = new ArrayList<>();
436      list.add(Integer.parseInt(value));
437      return list;
438    } catch (NumberFormatException e) {
439      Range<Integer> range = parseIntegerRange(value);
440      List<Integer> ints = new ArrayList<>();
441      if (range.hasLowerBound()) {
442        ints.add(range.lowerEndpoint());
443      }
444      if (range.hasUpperBound()) {
445        ints.add(range.upperEndpoint());
446      }
447      return ints;
448    }
449  }
450
451  private static void validateGeoDistance(String geoDistance) {
452    if (StringUtils.isEmpty(geoDistance)) {
453      throw new IllegalArgumentException("GeoDistance cannot be null or empty");
454    }
455    String[] geoDistanceTokens = geoDistance.split(",");
456    if (geoDistanceTokens.length != 3) {
457      throw new IllegalArgumentException("GeoDistance must follow the format lat,lng,distance");
458    }
459    validateGeoDistance(geoDistanceTokens[0], geoDistanceTokens[1], geoDistanceTokens[2]);
460  }
461
462
463  public static void validateGeoDistance(String latitude, String longitude, String distance) {
464    validateLatitude(latitude);
465    validateLongitude(longitude);
466    DistanceUnit.Distance parsedDistance = DistanceUnit.parseDistance(distance);
467    if (parsedDistance.getValue() <= 0d) {
468      throw new IllegalArgumentException("GeoDistance cannot be less than zero");
469    }
470 }
471
472  /**
473   * Parses a distance range in the format 123m,456km.
474   *
475   * @return the parsed range with wildcards represented as null values
476   * @throws IllegalArgumentException if value is invalid or null
477   */
478  public static Range<DistanceUnit.Distance> parseDistanceRange(String value) {
479    if (StringUtils.isNotEmpty(value)) {
480      Matcher m = DECIMAL_RANGE_PATTERN.matcher(value);
481      if (m.find()) {
482        return Range.closed(parseDistance(m.group(1)), parseDistance(m.group(2)));
483      }
484    }
485    throw new IllegalArgumentException("Invalid distance range: " + value);
486  }
487
488  public static DistanceUnit.Distance parseDistance(String distance) {
489    DistanceUnit.Distance parsedDistance = DistanceUnit.parseDistance(distance);
490    if (parsedDistance.getValue() <= 0d) {
491      throw new IllegalArgumentException("Distance cannot be less than zero");
492    }
493    return parsedDistance;
494  }
495
496  /**
497   * Validates if the parameter is a valid latitude value.
498   */
499  private static void validateLatitude(String value) {
500    validateDoubleInRange(value, LATITUDE_RNG, LATITUDE_ERROR_MSG);
501  }
502
503  /**
504   * Validates if the parameter is a valid longitude value.
505   */
506  private static void validateLongitude(String value) {
507    validateDoubleInRange(value, LONGITUDE_RNG, LONGITUDE_ERROR_MSG);
508  }
509
510  private static void validateMonth(Collection<Integer> months) {
511    for (Integer month : months) {
512      if (month != null && (month < 1 || month > 12)) {
513        throw new IllegalArgumentException("Month needs to be between 1 - 12");
514      }
515    }
516  }
517
518}