/* (c) 2017 Open Source Geospatial Foundation - all rights reserved * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.opensearch.eo.kvp; import static org.geoserver.opensearch.eo.OpenSearchParameters.GEO_BOX; import static org.geoserver.opensearch.eo.OpenSearchParameters.GEO_GEOMETRY; import static org.geoserver.opensearch.eo.OpenSearchParameters.GEO_LAT; import static org.geoserver.opensearch.eo.OpenSearchParameters.GEO_LON; import static org.geoserver.opensearch.eo.OpenSearchParameters.GEO_NAME; import static org.geoserver.opensearch.eo.OpenSearchParameters.GEO_RADIUS; import static org.geoserver.opensearch.eo.OpenSearchParameters.GEO_RELATION; import static org.geoserver.opensearch.eo.OpenSearchParameters.GEO_UID; import static org.geoserver.opensearch.eo.OpenSearchParameters.SEARCH_TERMS; import static org.geoserver.opensearch.eo.OpenSearchParameters.START_INDEX; import static org.geoserver.opensearch.eo.OpenSearchParameters.TIME_END; import static org.geoserver.opensearch.eo.OpenSearchParameters.TIME_RELATION; import static org.geoserver.opensearch.eo.OpenSearchParameters.TIME_START; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Date; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import org.geoserver.catalog.Predicates; import org.geoserver.config.GeoServer; import org.geoserver.opensearch.eo.OSEOInfo; import org.geoserver.opensearch.eo.OpenSearchEoService; import org.geoserver.opensearch.eo.OpenSearchParameters; import org.geoserver.opensearch.eo.OpenSearchParameters.DateRelation; import org.geoserver.opensearch.eo.OpenSearchParameters.GeometryRelation; import org.geoserver.opensearch.eo.SearchRequest; import org.geoserver.opensearch.eo.store.OpenSearchAccess; import org.geoserver.ows.KvpRequestReader; import org.geoserver.platform.OWS20Exception; import org.geoserver.platform.OWS20Exception.OWSExceptionCode; import org.geotools.data.Parameter; import org.geotools.data.Query; import org.geotools.factory.CommonFactoryFinder; import org.geotools.factory.Hints; import org.geotools.feature.NameImpl; import org.geotools.geometry.jts.ReferencedEnvelope; import org.geotools.util.ConverterFactory; import org.geotools.util.Converters; import org.opengis.filter.Filter; import org.opengis.filter.FilterFactory2; import org.opengis.filter.MultiValuedFilter.MatchAction; import org.opengis.filter.PropertyIsEqualTo; import org.opengis.filter.expression.Literal; import org.opengis.filter.expression.PropertyName; import org.opengis.filter.spatial.BBOX; import org.opengis.filter.spatial.DWithin; import org.springframework.util.StringUtils; import com.vividsolutions.jts.geom.Coordinate; import com.vividsolutions.jts.geom.Geometry; import com.vividsolutions.jts.geom.GeometryFactory; import com.vividsolutions.jts.geom.Point; import com.vividsolutions.jts.io.WKTReader; /** * Reads a "description" request * * @author Andrea Aime - GeoSolutions */ public class SearchRequestKvpReader extends KvpRequestReader { static final Pattern FULL_RANGE_PATTERN = Pattern .compile("^(\\[|\\])([^,\\[\\]]+),([^,\\\\[\\\\]]+)(\\[|\\])$"); static final Pattern LEFT_RANGE_PATTERN = Pattern.compile("^(\\[|\\])([^,\\[\\]]+)$"); static final Pattern RIGHT_RANGE_PATTERN = Pattern.compile("^([^,\\\\[\\\\]]+)(\\[|\\])$"); static final Pattern COMMA_SEPARATED = Pattern.compile("\\s*,\\s*"); private static final Hints SAFE_CONVERSION_HINTS = new Hints(ConverterFactory.SAFE_CONVERSION, true); private static final GeometryFactory GF = new GeometryFactory(); static final FilterFactory2 FF = CommonFactoryFinder.getFilterFactory2(); private static final PropertyName DEFAULT_GEOMETRY = FF.property(""); public static final String COUNT_KEY = "count"; public static final String PARENT_ID_KEY = "parentId"; private Set<String> NOT_FILTERS = new HashSet<>(Arrays.asList(START_INDEX.key, COUNT_KEY)); private OpenSearchEoService oseo; private GeoServer gs; OpenSearchBBoxKvpParser bboxParser = new OpenSearchBBoxKvpParser(); public SearchRequestKvpReader(GeoServer gs, OpenSearchEoService service) { super(SearchRequest.class); this.oseo = service; this.gs = gs; } @Override public Object read(Object requestObject, Map kvp, Map rawKvp) throws Exception { SearchRequest request = (SearchRequest) super.read(requestObject, kvp, rawKvp); // collect the valid search parameters Collection<Parameter<?>> parameters = getSearchParameters(request); Map<Parameter, String> parameterValues = getSearchParameterValues(rawKvp, parameters); request.setSearchParameters(parameterValues); // prepare query Query query = new Query(); request.setQuery(query); // get filters Filter filter = readFilter(rawKvp, parameters); query.setFilter(filter); // look at paging Integer count = getParameter(COUNT_KEY, rawKvp, Integer.class); if (count != null) { int ic = count.intValue(); if (ic < 0) { throw new OWS20Exception("Invalid 'count' value, should be positive or zero", OWSExceptionCode.InvalidParameterValue); } int configuredMaxFeatures = getConfiguredMaxFeatures(); if (ic > configuredMaxFeatures) { throw new OWS20Exception("Invalid 'count' value, should not be greater than " + configuredMaxFeatures, OWSExceptionCode.InvalidParameterValue); } query.setMaxFeatures(ic); } else { query.setMaxFeatures(getDefaultRecords()); } Integer startIndex = getParameter(START_INDEX.key, rawKvp, Integer.class); if (startIndex != null) { int is = startIndex.intValue(); if (is <= 0) { throw new OWS20Exception("Invalid 'startIndex' value, should be positive or zero", OWSExceptionCode.InvalidParameterValue); } query.setStartIndex(is - 1); // OS is 1 based, GeoTools is 0 based } return request; } private int getDefaultRecords() { OSEOInfo info = gs.getService(OSEOInfo.class); if (info == null) { return OSEOInfo.DEFAULT_RECORDS_PER_PAGE; } else { return info.getRecordsPerPage(); } } private int getConfiguredMaxFeatures() { OSEOInfo info = gs.getService(OSEOInfo.class); if (info == null) { return OSEOInfo.DEFAULT_MAXIMUM_RECORDS; } else { return info.getMaximumRecordsPerPage(); } } private Map<Parameter, String> getSearchParameterValues(Map rawKvp, Collection<Parameter<?>> parameters) { Map<Parameter, String> result = new LinkedHashMap<>(); for (Parameter<?> parameter : parameters) { Object value = rawKvp.get(parameter.key); if (value != null) { final String sv = Converters.convert(value, String.class); result.put(parameter, sv); } } return result; } private Filter readFilter(Map rawKvp, Collection<Parameter<?>> parameters) throws Exception { List<Filter> filters = new ArrayList<>(); for (Parameter<?> parameter : parameters) { Object value = rawKvp.get(parameter.key); if (!StringUtils.isEmpty(value) && !NOT_FILTERS.contains(parameter.key)) { Filter filter = null; if (SEARCH_TERMS.key.equals(parameter.key)) { filter = buildSearchTermsFilter(value); } else if (GEO_UID.key.equals(parameter.key)) { filter = buildUidFilter(value); } else if (GEO_BOX.key.equals(parameter.key)) { filter = buildBoundingBoxFilter(value); } else if (GEO_LAT.key.equals(parameter.key)) { filter = buildLatLonDistanceFilter(rawKvp); } else if (GEO_NAME.key.equals(parameter.key)) { filter = buildNameDistanceFilter(rawKvp); } else if (isEoParameter(parameter)) { filter = buildEoFilter(parameter, value); } if (filter != null) { filters.add(filter); } } } // handle time filters (can go between 1 to 3 params) Filter timeFilter = buildTimeFilter(rawKvp); if (timeFilter != null) { filters.add(timeFilter); } // handle geometry filter (2 params) Filter geoFilter = buildGeometryFilter(rawKvp); if(geoFilter != null) { filters.add(geoFilter); } Filter filter = Predicates.and(filters); return filter; } private Filter buildTimeFilter(Map rawKvp) { final Object rawStart = rawKvp.get(TIME_START.key); Date start = Converters.convert(rawStart, Date.class); final Object rawEnd = rawKvp.get(TIME_END.key); Date end = Converters.convert(rawEnd, Date.class); final Object rawRelation = rawKvp.get(TIME_RELATION.key); // some validation DateRelation relation = Converters.convert(rawRelation, DateRelation.class); if (relation == null && rawRelation != null) { final List<String> dateRelationNames = Arrays.stream(DateRelation.values()) .map(k -> k.name()).collect(Collectors.toList()); throw new OWS20Exception( "Invalid value for relation, possible values are " + dateRelationNames, OWS20Exception.OWSExceptionCode.InvalidParameterValue, TIME_RELATION.key); } if (start == null && rawStart != null) { throw new OWS20Exception( "Invalid expression for start time, use a ISO time or date instead: " + rawStart, OWS20Exception.OWSExceptionCode.InvalidParameterValue, TIME_START.key); } if (end == null && rawEnd != null) { throw new OWS20Exception( "Invalid expression for end time, use a ISO time or date instead: " + rawStart, OWS20Exception.OWSExceptionCode.InvalidParameterValue, TIME_END.key); } if (start == null && end == null) { if (relation == null) { // nothing specified return null; } else { throw new OWS20Exception( "Time relation specified, but start and end time values are missing", OWS20Exception.OWSExceptionCode.InvalidParameterValue, TIME_RELATION.key); } } // default if null if (relation == null) { relation = DateRelation.intersects; } // build the filter final PropertyName startProperty = FF.property("timeStart"); final PropertyName endProperty = FF.property("timeEnd"); switch (relation) { case contains: // the resource contains the specified range Filter fStart; if (start == null) { fStart = FF.isNull(startProperty); } else { fStart = FF.lessOrEqual(startProperty, FF.literal(start)); } Filter fEnd; if (end == null) { fEnd = FF.isNull(endProperty); } else { fEnd = FF.greaterOrEqual(endProperty, FF.literal(end)); } return FF.and(fStart, fEnd); case during: // the resource is contained in the specified range fStart = FF.greaterOrEqual(startProperty, FF.literal(start)); fEnd = FF.lessOrEqual(endProperty, FF.literal(end)); if (start == null) { return fEnd; } else if (end == null) { return fStart; } else { return FF.and(fStart, fEnd); } case disjoint: // the resource is not overlapping the specified range fStart = FF.less(endProperty, FF.literal(start)); fEnd = FF.greater(startProperty, FF.literal(end)); if (start == null) { return fEnd; } else if (end == null) { return fStart; } else { return FF.or(fStart, fEnd); } case intersects: // the resource overlaps the specified range fStart = FF.or(FF.greaterOrEqual(endProperty, FF.literal(start)), FF.isNull(endProperty)); fEnd = FF.or(FF.lessOrEqual(startProperty, FF.literal(end)), FF.isNull(startProperty)); if (start == null) { return fEnd; } else if (end == null) { return fStart; } else { return FF.and(fStart, fEnd); } case equals: // the resource has the same range as requested if (start == null) { fStart = FF.isNull(startProperty); } else { fStart = FF.equals(startProperty, FF.literal(start)); } if (end == null) { fEnd = FF.isNull(endProperty); } else { fEnd = FF.equals(endProperty, FF.literal(end)); } return FF.and(fStart, fEnd); default: throw new RuntimeException("Time relation of type " + relation + " not covered yet"); } } private Filter buildLatLonDistanceFilter(Map rawKvp) { Double lat = Converters.convert(rawKvp.get(GEO_LAT.key), Double.class); Double lon = Converters.convert(rawKvp.get(GEO_LON.key), Double.class); Double radius = Converters.convert(rawKvp.get(GEO_RADIUS.key), Double.class); if (lat == null || lon == null || radius == null) { throw new OWS20Exception( "When specifying a distance search, lat, lon and radius must all be specified at the same time", OWS20Exception.OWSExceptionCode.InvalidParameterValue); } return buildDistanceWithin(lon, lat, radius); } private Filter buildNameDistanceFilter(Map rawKvp) { String name = Converters.convert(rawKvp.get(GEO_NAME.key), String.class); Double radius = Converters.convert(rawKvp.get(GEO_RADIUS.key), Double.class); if (name == null || radius == null) { throw new OWS20Exception( "When specifying a distance search, name and radius must both be specified", OWS20Exception.OWSExceptionCode.InvalidParameterValue); } throw new UnsupportedOperationException( "Still have to code or or more ways to geocode a name"); } private Filter buildDistanceWithin(double lon, double lat, double radius) { if (radius <= 0) { throw new OWS20Exception("Search radius must be positive", OWS20Exception.OWSExceptionCode.InvalidParameterValue, "radius"); } final Point point = GF.createPoint(new Coordinate(lon, lat)); DWithin dwithin = FF.dwithin(DEFAULT_GEOMETRY, FF.literal(point), radius, "m"); return dwithin; } private Filter buildBoundingBoxFilter(Object value) throws Exception { Filter filter; Object parsed = bboxParser.parse((String) value); if(parsed instanceof ReferencedEnvelope) { filter = FF.bbox(DEFAULT_GEOMETRY, (ReferencedEnvelope) parsed, MatchAction.ANY); } else if(parsed instanceof ReferencedEnvelope[]) { ReferencedEnvelope[] envelopes = (ReferencedEnvelope[]) parsed; BBOX bbox1 = FF.bbox(DEFAULT_GEOMETRY, envelopes[0], MatchAction.ANY); BBOX bbox2 = FF.bbox(DEFAULT_GEOMETRY, envelopes[1], MatchAction.ANY); return FF.or(bbox1, bbox2); } else { throw new IllegalArgumentException("Unexpected bbox parse result: " + parsed); } return filter; } private Filter buildGeometryFilter(Map rawKvp) { String rawGeometry = (String) rawKvp.get(GEO_GEOMETRY.key); String rawRelation = Converters.convert(rawKvp.get(GEO_RELATION.key), String.class); if(rawGeometry == null && rawRelation == null) { return null; } Geometry geometry; try { geometry = new WKTReader().read(rawGeometry); } catch(Exception e) { throw new OWS20Exception( "Could not parse geometry parameter, expecting valid WKT syntax: " + e.getMessage(), e, OWS20Exception.OWSExceptionCode.InvalidParameterValue, "geometry"); } // handle relation GeometryRelation relation = Converters.convert(rawRelation, GeometryRelation.class); if (relation == null && rawRelation != null) { final List<String> geoRelationNames = Arrays.stream(GeometryRelation.values()) .map(k -> k.name()).collect(Collectors.toList()); throw new OWS20Exception( "Invalid value for relation, possible values are " + geoRelationNames, OWS20Exception.OWSExceptionCode.InvalidParameterValue, GEO_RELATION.key); } if(relation == null) { relation = GeometryRelation.intersects; } // build the filter switch(relation) { case intersects: return FF.intersects(DEFAULT_GEOMETRY, FF.literal(geometry)); case contains: return FF.contains(FF.literal(geometry), DEFAULT_GEOMETRY); case disjoint: return FF.disjoint(DEFAULT_GEOMETRY, FF.literal(geometry)); default: throw new RuntimeException("Geometry relation of type " + relation + " not covered yet"); } } private PropertyIsEqualTo buildUidFilter(Object value) { return FF.equals(FF.property(new NameImpl(OpenSearchAccess.EO_NAMESPACE, "identifier")), FF.literal(value)); } private Filter buildSearchTermsFilter(Object value) { String converted = getParameter(SEARCH_TERMS.key, value, String.class); // split into parts separated by spaces, but not bits in double quotes Pattern MATCH_TERMS_SPLITTER = Pattern.compile("([^\"]\\S*|\".+?\")\\s*"); Matcher m = MATCH_TERMS_SPLITTER.matcher(converted); List<String> keywords = new ArrayList<>(); while (m.find()) { String group = m.group(1); if (group.startsWith("\"") && group.endsWith("\"") && group.length() > 1) { group = group.substring(1, group.length() - 1); } keywords.add(group); } // turn into a list of Like filters // TODO: actually implement a full text search function List<Filter> filters = keywords.stream() .map(s -> FF.like(FF.property("htmlDescription"), "%" + s + "%")) .collect(Collectors.toList()); // combine and return Filter result = Predicates.or(filters); return result; } private <T> T getParameter(String key, Map rawKvp, Class<T> targetClass) { Object value = rawKvp.get(key); if (value == null) { return null; } else { return getParameter(key, value, targetClass); } } private <T> T getParameter(String key, Object value, Class<T> targetClass) { T converted = Converters.convert(value, targetClass, SAFE_CONVERSION_HINTS); if (converted == null) { throw new OWS20Exception( key + " cannot be converted to a " + targetClass.getSimpleName(), OWSExceptionCode.InvalidParameterValue, key); } return converted; } private boolean isEoParameter(Parameter parameter) { String prefix = OpenSearchParameters.getParameterPrefix(parameter); if (prefix == null) { return false; } // collectin parameter? if (prefix.equals(OpenSearchParameters.EO_PREFIX)) { return true; } // product parameter? for (OpenSearchAccess.ProductClass pc : OpenSearchAccess.ProductClass.values()) { if (pc.getPrefix().equals(prefix)) { return true; } } return false; } private Filter buildEoFilter(Parameter<?> parameter, Object value) { // support two types of filters, equality and range filters Class<?> type = parameter.getType(); PropertyName pn = OpenSearchParameters.getFilterPropertyFor(FF, parameter); // for numeric and range parameters check the range syntax String input = (String) value; if (Date.class.isAssignableFrom(type) || Number.class.isAssignableFrom(type)) { Matcher matcher; if ((matcher = FULL_RANGE_PATTERN.matcher(input)).matches()) { String opening = matcher.group(1); String s1 = matcher.group(2); String s2 = matcher.group(3); String closing = matcher.group(4); // parse and check they are actually valid numbers/dates Object v1 = parseParameter(parameter, s1); Object v2 = parseParameter(parameter, s2); Filter f1, f2; Literal l1 = FF.literal(v1); Literal l2 = FF.literal(v2); if ("[".equals(opening)) { f1 = FF.greaterOrEqual(pn, l1); } else { f1 = FF.greater(pn, l1); } if ("]".equals(closing)) { f2 = FF.lessOrEqual(pn, l2); } else { f2 = FF.less(pn, l2); } return FF.and(f1, f2); } else if ((matcher = LEFT_RANGE_PATTERN.matcher(input)).matches()) { String opening = matcher.group(1); String s1 = matcher.group(2); // parse and check they are actually valid numbers/dates Object v1 = parseParameter(parameter, s1); Literal l1 = FF.literal(v1); if ("[".equals(opening)) { return FF.greaterOrEqual(pn, l1); } else { return FF.greater(pn, l1); } } else if ((matcher = RIGHT_RANGE_PATTERN.matcher(input)).matches()) { String s2 = matcher.group(1); String closing = matcher.group(2); // parse and check they are actually valid numbers/dates Object v2 = parseParameter(parameter, s2); Literal l2 = FF.literal(v2); if ("]".equals(closing)) { return FF.lessOrEqual(pn, l2); } else { return FF.less(pn, l2); } } } // we got here, it's not a valid range, see if it's a comma separated list vs single value then if (input.contains(",")) { String[] splits = COMMA_SEPARATED.split(input); List<Filter> filters = new ArrayList<>(); for (String split : splits) { Filter filter = buildEqualityFilter(parameter, pn, split); filters.add(filter); } return FF.or(filters); } else { // ok, single equality filter then Filter filter = buildEqualityFilter(parameter, pn, input); return filter; } } private Filter buildEqualityFilter(Parameter<?> parameter, PropertyName pn, String input) { Object converted = parseParameter(parameter, input); return FF.equal(pn, FF.literal(converted), true); } private Object parseParameter(Parameter<?> parameter, String value) { Object converted = Converters.convert(value, parameter.getType(), SAFE_CONVERSION_HINTS); if (converted == null) { throw new OWS20Exception( "Value '" + value + "' of key " + parameter.key + " cannot be converted to a " + parameter.getType().getSimpleName(), OWSExceptionCode.InvalidParameterValue, parameter.key); } return converted; } private Collection<Parameter<?>> getSearchParameters(SearchRequest request) throws IOException { String parentId = request.getParentId(); if (parentId == null) { return oseo.getCollectionSearchParameters(); } else { return oseo.getProductSearchParameters(parentId); } } }