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}