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}