/* (c) 2014 Open Source Geospatial Foundation - all rights reserved * (c) 2001 - 2013 OpenPlans * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.ows.util; import java.io.UnsupportedEncodingException; import java.lang.reflect.Array; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.ListIterator; import java.util.Map; import java.util.StringTokenizer; import java.util.logging.Level; import java.util.logging.Logger; import org.geoserver.ows.KvpParser; import org.geoserver.platform.GeoServerExtensions; import org.geoserver.platform.ServiceException; import org.geotools.util.Version; /** * Utility class for reading Key Value Pairs from a http query string. * * @author Rob Hranac, TOPP * @author Chris Holmes, TOPP * @author Gabriel Rold?n, Axios * @author Justin Deoliveira, TOPP * @author Carlo Cancellieri Geo-Solutions SAS * * @version $Id$ */ public class KvpUtils { /** Class logger */ private static Logger LOGGER = org.geotools.util.logging.Logging.getLogger("org.vfny.geoserver.requests.readers"); /** * Defines how to tokenize a string by using some sort of delimiter. * <p> * Default implementation uses {@link String#split(String)} with the * regular expression provided at the constructor. More specialized * subclasses may just override <code>readFlat(String)</code>. * </p> * @author Gabriel Roldan * @since 1.6.0 */ public static class Tokenizer { private String regExp; public Tokenizer(String regExp) { this.regExp = regExp; } private String getRegExp() { return regExp; } public String toString() { return getRegExp(); } public List readFlat(final String rawList){ if ((rawList == null || rawList.trim().equals(""))) { return Collections.EMPTY_LIST; } else if (rawList.equals("*")) { // handles explicit unconstrained case return Collections.EMPTY_LIST; } // -1 keeps trailing empty strings in the pack String[] split = rawList.split(getRegExp(), -1); return new ArrayList(Arrays.asList(split)); } } /** Delimeter for KVPs in the raw string */ public static final Tokenizer KEYWORD_DELIMITER = new Tokenizer("&"); /** Delimeter that seperates keywords from values */ public static final Tokenizer VALUE_DELIMITER = new Tokenizer("="); /** Delimeter for outer value lists in the KVPs */ public static final Tokenizer OUTER_DELIMETER = new Tokenizer("\\)\\(") { public List readFlat(final String rawList) { List list = new ArrayList(super.readFlat(rawList)); final int len = list.size(); if (len > 0) { String first = (String) list.get(0); if (first.startsWith("(")) { list.set(0, first.substring(1)); } String last = (String) list.get(len - 1); if (last.endsWith(")")) { list.set(len - 1, last.substring(0, last.length() - 1)); } } return list; } }; /** Delimeter for inner value lists in the KVPs */ public static final Tokenizer INNER_DELIMETER = new Tokenizer(","); /** Delimeter for multiple filters in a CQL filter list (<code>";"</code>) */ public static final Tokenizer CQL_DELIMITER = new Tokenizer(";"); /** * Attempts to parse out the proper typeNames from the FeatureId filters. * It simply uses the value before the '.' character. * * @param rawFidList the strings after the FEATUREID url component. Should * be found using kvpPairs.get("FEATUREID") in this class or one of * its children * * @return A list of typenames, made from the featureId filters. */ @SuppressWarnings("rawtypes") public static List getTypesFromFids(String rawFidList) { List typeList = new ArrayList(); List unparsed = readNested(rawFidList); Iterator i = unparsed.listIterator(); while (i.hasNext()) { List ids = (List) i.next(); ListIterator innerIterator = ids.listIterator(); while (innerIterator.hasNext()) { String fid = innerIterator.next().toString(); LOGGER.finer("looking at featureId" + fid); String typeName = fid.substring(0, fid.lastIndexOf(".")); LOGGER.finer("adding typename: " + typeName + " from fid"); typeList.add(typeName); } } return typeList; } /** * Calls {@link #readFlat(String)} with the {@link #INNER_DELIMETER}. * */ public static List readFlat(String rawList) { return readFlat(rawList, INNER_DELIMETER); } /** * Reads a tokenized string and turns it into a list. * <p> * In this method, the tokenizer is actually responsible to scan the string, * so this method is just a convenience to maintain backwards compatibility * with the old {@link #readFlat(String, String)} and to easy the use of the * default tokenizers {@link #KEYWORD_DELIMITER}, {@link #INNER_DELIMETER}, * {@link #OUTER_DELIMETER} and {@link #VALUE_DELIMITER}. * </p> * <p> * Note that if the list is unspecified (ie. is null) or is unconstrained * (ie. is ''), then the method returns an empty list. * </p> * * @param rawList * The tokenized string. * @param tokenizer * The delimeter for the string tokens. * * @return A list of the tokenized string. * @see Tokenizer */ public static List readFlat(final String rawList, final Tokenizer tokenizer) { return tokenizer.readFlat(rawList); } /** * Reads a tokenized string and turns it into a list. In this method, the * tokenizer is quite flexible. Note that if the list is unspecified (ie. is * null) or is unconstrained (ie. is ''), then the method returns an empty * list. * <p> * If possible, use the method version that receives a well known * {@link #readFlat(String, org.geoserver.ows.util.KvpUtils.Tokenizer) Tokenizer}, * as there might be special cases to catch out, like for the * {@link #OUTER_DELIMETER outer delimiter "()"}. If this method delimiter * argument does not match a well known Tokenizer, it'll use a simple string * tokenization based on splitting out the strings with the raw passed in * delimiter. * </p> * * @param rawList * The tokenized string. * @param delimiter * The delimeter for the string tokens. * * @return A list of the tokenized string. * * @see #readFlat(String, org.geoserver.ows.util.KvpUtils.Tokenizer) */ public static List readFlat(String rawList, String delimiter) { Tokenizer delim; if (KEYWORD_DELIMITER.getRegExp().equals(delimiter)) { delim = KEYWORD_DELIMITER; } else if (VALUE_DELIMITER.getRegExp().equals(delimiter)) { delim = VALUE_DELIMITER; } else if (OUTER_DELIMETER.getRegExp().equals(delimiter)) { delim = OUTER_DELIMETER; } else if (INNER_DELIMETER.getRegExp().equals(delimiter)) { delim = INNER_DELIMETER; }else if(CQL_DELIMITER.getRegExp().equals(delimiter)){ delim = CQL_DELIMITER; } else { LOGGER.fine("Using not a well known kvp tokenization delimiter: " + delimiter); delim = new Tokenizer(delimiter); } return readFlat(rawList, delim); } /** * Reads a nested tokenized string and turns it into a list. This method is * much more specific to the KVP get request syntax than the more general * readFlat method. In this case, the outer tokenizer '()' and inner * tokenizer ',' are both from the specification. Returns a list of lists. * * @param rawList * The tokenized string. * * @return A list of lists, containing outer and inner elements. */ public static List readNested(String rawList) { if (LOGGER.isLoggable(Level.FINEST)) { LOGGER.finest("reading nested: " + rawList); } List kvpList = new ArrayList(10); // handles implicit unconstrained case if (rawList == null) { if (LOGGER.isLoggable(Level.FINEST)) { LOGGER.finest("found implicit all requested"); } kvpList.add(Collections.EMPTY_LIST); return kvpList; // handles explicit unconstrained case } else if (rawList.equals("*")) { if (LOGGER.isLoggable(Level.FINEST)) { LOGGER.finest("found explicit all requested"); } kvpList.add(Collections.EMPTY_LIST); return kvpList; // handles explicit, constrained element lists } else { if (LOGGER.isLoggable(Level.FINEST)) { LOGGER.finest("found explicit requested"); } // handles multiple elements list case if (rawList.startsWith("(")) { if (LOGGER.isLoggable(Level.FINEST)) { LOGGER.finest("reading complex list"); } List outerList = readFlat(rawList, OUTER_DELIMETER); Iterator i = outerList.listIterator(); while (i.hasNext()) { kvpList.add(readFlat((String) i.next(), INNER_DELIMETER)); } // handles single element list case } else { if (LOGGER.isLoggable(Level.FINEST)) { LOGGER.finest("reading simple list"); } kvpList.add(readFlat(rawList, INNER_DELIMETER)); } return kvpList; } } /** * Cleans an HTTP string and returns pure ASCII as a string. * * @param raw The HTTP-encoded string. * * @return The string with the url escape characters replaced. */ public static String clean(String raw) { LOGGER.finest("raw request: " + raw); String clean = null; if (raw != null) { try { clean = java.net.URLDecoder.decode(raw, "UTF-8"); } catch (java.io.UnsupportedEncodingException e) { LOGGER.finer("Bad encoding for decoder " + e); } } else { return ""; } LOGGER.finest("cleaned request: " + raw); return clean; } /** * @param kvp unparsed/unormalized kvp set */ public static KvpMap normalize( Map kvp ) { if ( kvp == null ) { return null; } //create a normalied map KvpMap normalizedKvp = new KvpMap(); for (Iterator itr = kvp.entrySet().iterator(); itr.hasNext();) { Map.Entry entry = (Map.Entry) itr.next(); String key = (String) entry.getKey(); Object value = null; if (entry.getValue() instanceof String) { value = trim((String) entry.getValue()); } else if (entry.getValue() instanceof String[]) { String[] values = (String[]) entry.getValue(); // we use a set so that mere value repetition (a common error for which the OWS spec // leaves the server up to decide what to do) does not cause the result to be a String[] LinkedHashSet<String> normalized = new LinkedHashSet<String>(); for (String v : values) { v = trim(v); if(v != null) { normalized.add(v); } } if(normalized.size() == 0) { value = null; } else if(normalized.size() == 1) { value = normalized.iterator().next(); } else { value = (String[]) normalized.toArray(new String[normalized.size()]); } } //convert key to lowercase normalizedKvp.put(key.toLowerCase(), value); } return normalizedKvp; } private static String trim(String value) { // trim the string if ( value != null ) { value = value.trim(); } return value; } /** * Parses a map of key value pairs. * <p> * Important: This method modifies the map, overriding original values with * parsed values. * </p> * <p> * This routine performs a lookup of {@link KvpParser} to parse the kvp * entries. * </p> * <p> * If an individual parse fails, this method saves the exception, and adds * it to the list that is returned. * </p> * * @param kvp raw or unparsed kvp. * * @return A list of errors that occured. */ public static List<Throwable> parse(Map kvp) { // look up parser objects List<KvpParser> parsers = GeoServerExtensions.extensions(KvpParser.class); //strip out parsers which do not match current service/request/version String service = KvpUtils.getSingleValue(kvp, "service"); String version = KvpUtils.getSingleValue(kvp, "version"); String request = KvpUtils.getSingleValue(kvp, "request"); purgeParsers(parsers, service, version, request); // parser the kvp's ArrayList<Throwable> errors = new ArrayList<Throwable>(); for (Iterator<Map.Entry<Object, Object>> itr = kvp.entrySet().iterator(); itr.hasNext();) { Map.Entry<Object, Object> entry = itr.next(); String key = (String) entry.getKey(); // find the parser for this key value pair KvpParser parser = findParser(key, service, request, version, parsers); // parse the value Object parsed = null; if (parser != null) { try { if (entry.getValue() instanceof String) { String value = (String) entry.getValue(); parsed = parser.parse(value); } else { String[] values = (String[]) entry.getValue(); List<Object> result = new ArrayList<Object>(); for (String v : values) { result.add(parser.parse(v)); } parsed = result; } } catch (Throwable t) { // dont throw any exceptions yet, befor the service is // known errors.add(t); } } // We only change the value of the parameter if the parser was found and no exception is thrown (parsed != null) If so (==null) it is // untouched (remains a String) if (parsed != null) { entry.setValue(parsed); } } return errors; } /** * Strip out parsers which do not match current service/request/version * * @param parsers list of {@link KvpParser} to purge (see {@link GeoServerExtensions#extensions(Class)}) * @param service the service parameter from the kvp (can be null) * @param version the version parameter from the kvp (can be null) * @param request the request parameter from the kvp (can be null) */ public static void purgeParsers(List<KvpParser> parsers, final String service, final String version, final String request) { for (Iterator<KvpParser> p = parsers.iterator(); p.hasNext();) { KvpParser parser = p.next(); if (parser.getService() != null && !parser.getService().equalsIgnoreCase(service)) { p.remove(); } else if (parser.getVersion() != null && !parser.getVersion().toString().equals(version)) { p.remove(); } else if (parser.getRequest() != null && !parser.getRequest().equalsIgnoreCase(request)) { p.remove(); } } } /** * Find a parser for the passed key into registered parsers ({@link KvpParser}) * * @param key the key matching the value to parse * @param service the service parameter from the kvp (can be null) * @param version the version parameter from the kvp (can be null) * @param request the request parameter from the kvp (can be null) * @param parsers the purged parsers list (see {@link #purgeParsers(List, String, String, String)} * @return the found parser or null (if no parser is found) * @throws IllegalStateException if more than one candidate parser is found */ public static KvpParser findParser(final String key, final String service, final String request, final String version, Collection<KvpParser> parsers) { // find the parser for this key value pair KvpParser parser = null; final Iterator<KvpParser> pitr = parsers.iterator(); while (pitr.hasNext()) { KvpParser candidate = pitr.next(); if (key.equalsIgnoreCase(candidate.getKey())) { if (parser == null) { parser = candidate; } else { // if target service matches, it is a closer match String trgService = candidate.getService(); if (trgService != null && trgService.equalsIgnoreCase(service)) { // determine if this parser more closely matches the request String curService = parser.getService(); if (curService == null) { parser = candidate; } else { // both match, filter by version Version curVersion = parser.getVersion(); Version trgVersion = candidate.getVersion(); if (trgVersion != null) { if (curVersion == null && trgVersion.toString().equals(version)) { parser = candidate; } } else { if (curVersion == null) { // ambiguous, unable to match throw new IllegalStateException("Multiple kvp parsers: " + parser + "," + candidate); } } } } } } } return parser; } /** * Parse this key value pair using registered parsers ({@link KvpParser}) * * @param key the key matching the value to parse * @param value the value to parse * @param service the service parameter from the kvp (can be null) * @param version the version parameter from the kvp (can be null) * @param request the request parameter from the kvp (can be null) * @param parsers the purged parsers list (see {@link #purgeParsers(List, String, String, String)} * @return the parsed value or null (if no parser is found) * @throws Exception if the selected parser throws an exception * @throws IllegalStateException if more than one candidate parser is found */ public static Object parseKey(final String key, final String value, final String service, final String request, final String version, List<KvpParser> parsers) throws Exception { // find the parser for this key value pair KvpParser parser = findParser(key, service, request, version, parsers); if (parser == null) { return null; } return parser.parse(value); } /** * Returns a single value for the specified key from the raw KVP, or throws an exception * if multiple different values are found * * @param kvp map of key value pairs * @param key key used to lookup a single value * */ public static String getSingleValue(Map kvp, String key) { Object value = kvp.get(key); if(value == null) { return null; } else if(value instanceof String) { return (String) value; } else { String[] strings = (String[]) value; if(strings.length == 0) { return null; } String result = strings[0]; for (int i = 1; i < strings.length; i++) { if(!result.equals(strings[i])) { throw new ServiceException("Single value expected for request parameter " + key + " but instead found: " + Arrays.toString(strings), ServiceException.INVALID_PARAMETER_VALUE, key); } } return result; } } /** * Parses the parameters in the path query string. Normally this is done by the * servlet container but in a few cases (testing for example) we need to emulate the container * instead. * * @param path a url in the form path?k1=v1&k2=v2&,,, * */ public static Map<String, Object> parseQueryString(String path) { int index = path.indexOf('?'); if (index == -1) { return Collections.EMPTY_MAP; } String queryString = path.substring(index + 1); StringTokenizer st = new StringTokenizer(queryString, "&"); Map<String, Object> result = new HashMap<String, Object>(); while (st.hasMoreTokens()) { String token = st.nextToken(); String[] keyValuePair; int idx = token.indexOf('='); if(idx > 0) { keyValuePair = new String[2]; keyValuePair[0] = token.substring(0, idx); keyValuePair[1] = token.substring(idx + 1); } else { keyValuePair = new String[1]; keyValuePair[0] = token; } //check for any special characters if ( keyValuePair.length > 1 ) { //replace any equals or & characters try { // if this one does not work first check if the url encoded content is really // properly encoded. I had good success with this: http://meyerweb.com/eric/tools/dencoder/ keyValuePair[1] = URLDecoder.decode(keyValuePair[1], "ISO-8859-1"); } catch(UnsupportedEncodingException e) { throw new RuntimeException("Totally unexpected... is your JVM busted?", e); } } String key = keyValuePair[0]; String value = keyValuePair.length > 1 ? keyValuePair[1] : ""; if(result.get(key) == null) { result.put(key, value); } else { String[] array; Object oldValue = result.get(key); if(oldValue instanceof String) { array = new String[2]; array[0] = (String) oldValue; array[1] = value; } else { String[] oldArray = (String[]) oldValue; array = new String[oldArray.length + 1]; System.arraycopy(oldArray, 0, array, 0, oldArray.length); array[oldArray.length] = value; } result.put(key, array); } } return result; } /** * Tokenize a String using the specified separator character and the backslash as an escape * character (see OGC WFS 1.1.0 14.2.2). Escape characters within the tokens are not resolved. * * @param s the String to parse * @param separator the character that separates tokens * * @return list of tokens */ public static List<String> escapedTokens(String s, char separator) { return escapedTokens(s, separator, 0); } /** * Tokenize a String using the specified separator character and the backslash as an escape * character (see OGC WFS 1.1.0 14.2.2). Escape characters within the tokens are not resolved. * * @param s the String to parse * @param separator the character that separates tokens * @param maxTokens ignoring escaped separators, the maximum number of tokens to return. A value of 0 has no maximum. * * @return list of tokens */ public static List<String> escapedTokens(String s, char separator, int maxTokens) { if (s == null) { throw new IllegalArgumentException("The String to parse may not be null."); } if (separator == '\\') { throw new IllegalArgumentException("The separator may not be a backslash."); } if (maxTokens <= 0) { maxTokens = Integer.MAX_VALUE; } List<String> ret = new ArrayList<String>(); StringBuilder sb = new StringBuilder(); boolean escaped = false; int tokenCount = 1; for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (c == separator && !escaped && tokenCount < maxTokens) { ret.add(sb.toString()); sb.setLength(0); tokenCount++; } else { if (escaped) { escaped = false; sb.append('\\'); sb.append(c); } else if (c == '\\') { escaped = true; } else { sb.append(c); } } } if (escaped) { throw new IllegalStateException("The specified String ends with an incomplete escape sequence."); } ret.add(sb.toString()); return ret; } /** * Resolve escape sequences in a String. * * @param s the String to unescape * * @return resolved String */ public static String unescape(String s) { if (s == null) { throw new IllegalArgumentException("The String to unescape may not be null."); } StringBuilder sb = new StringBuilder(); boolean escaped = false; for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (escaped) { escaped = false; sb.append(c); } else if (c == '\\') { escaped = true; } else { sb.append(c); } } if (escaped) { throw new IllegalArgumentException("The specified String ends with an incomplete escape sequence."); } return sb.toString(); } public static String caseInsensitiveParam(Map params, String paramname, String defaultValue) { String value = defaultValue; for (Object o : params.entrySet()) { Map.Entry entry = (Map.Entry) o; if (entry.getKey() instanceof String) { if (paramname.equalsIgnoreCase((String) entry.getKey())) { Object obj = entry.getValue(); value = obj instanceof String ? (String) obj : (obj instanceof String[]) ? ((String[]) obj)[0].toLowerCase() : value; } } } return value; } public static void merge(Map options, Map addition) { for (Object o : addition.entrySet()) { Map.Entry entry = (Map.Entry) o; if (entry.getValue() == null) options.remove(entry.getKey()); else options.put(entry.getKey(), entry.getValue()); } } /** * Extracts the first value for the specified parameter (the kvp can contain either a single * string, or an array of values) * @param kvp map of key value pairs * @param param retrieve the first value for the parameter */ public static String firstValue(Map kvp, String param) { Object o = kvp.get(param); if(o == null) { return null; } else if(o instanceof String) { return (String) o; } else { String[] values = (String[]) o; if(values.length >= 0) { return values[0]; } else { return null; } } } }