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}